Compare commits

...

14 Commits

Author SHA1 Message Date
Tom Moor 004cf59988 stash 2021-02-10 19:56:59 -08:00
Tom Moor df472ac391 feat: add total users to people management screen (#1878)
* feat: add total users to pagination

* move this.total in runInAction callback

* add total counts + counts to people tabs

* progress: use raw pg query

* progress: add test

* fix: SQL interpolation

* Styling and translation of People page

Co-authored-by: Tim <timothychang94@gmail.com>
2021-02-09 20:13:09 -08:00
Tom Moor 097359bf7c feat: Added ability to disable sharing at collection (#1875)
* feat: Added ability to disable sharing at collection

* fix: Disable all previous share links when disabling collection share
Language

* fix: Disable document sharing for read-only collection members

* wip

* test

* fix: Clear policies after updating sharing settings

* chore: Less ambiguous language

* feat: Allow setting sharing choice on collection creation
2021-02-09 19:04:03 -08:00
Tom Moor cc90c8de1c feat: Sidebar Improvements (#1862)
* wip

* refactor behaviorg

* stash

* simplify
2021-02-07 21:51:56 -08:00
Tom Moor ac6c48817c fix: Unable to select .md files by default on some machines depending on installed software 2021-02-07 16:14:03 -08:00
Tom Moor 8e3534dcbc fix: File import via collection menu regression 2021-02-07 16:13:44 -08:00
Yaroslav Zhavoronkov e2d7d34f30 fix: Pass credentials with API requests when required to work with Cloudflare Access (#1867) 2021-02-06 22:49:49 -08:00
Tom Moor 0d88a1dfda Update README.md 2021-02-06 21:49:07 -08:00
Tom Moor df5a2e45c5 chore: Improved deployment documentation (#1868) 2021-02-06 21:33:56 -08:00
Tom Moor 1a7a48674b fix: link in README, add ARCHITECTURE document 2021-02-06 17:46:54 -08:00
Tom Moor e23474fa1c feat: Add parameters for filtering events (#1863)
* feat: Add parameters for filtering events

* test
2021-02-04 20:20:56 -08:00
Tom Moor 37fa13d841 fix: flash of 'Deleted Collection' when loading app on doc page 2021-02-02 22:03:02 -08:00
Tom Moor 6d88c02869 chore: Remove unused Popover component 2021-02-02 21:17:17 -08:00
Tom Moor a2fb3bb9f8 fix: Favicon should load from domain root, not current path 2021-02-02 21:13:11 -08:00
54 changed files with 1182 additions and 605 deletions
+73 -28
View File
@@ -1,31 +1,40 @@
# Copy this file to .env, remove this comment and change the keys. For development
# with docker this should mostly work out of the box other than setting the Slack
# keys (for auth) and the SECRET_KEY.
#
# Please use `openssl rand -hex 32` to create SECRET_KEY
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
SECRET_KEY=generate_a_new_key
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
# Must point to the publicly accessible URL for the installation
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=http://localhost:3000
PORT=3000
# Optional. If using a Cloudfront distribution or similar the origin server
# should be set to the same as URL.
CDN_URL=
# Third party signin credentials, at least one of EITHER Google OR Slack is
# required for a working installation or you'll have no sign-in options.
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
ENABLE_UPDATES=true
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Third party signin credentials (at least one is required)
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
@@ -33,22 +42,59 @@ SLACK_SECRET=get_the_secret_of_above_key
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<your Outline URL>/auth/google.callback
# https://<URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Comma separated list of domains to be allowed (optional)
# If not set, all Google apps domains are allowed by default
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
GOOGLE_ALLOWED_DOMAINS=
# Third party credentials (optional)
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# AWS credentials (optional in development)
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -56,11 +102,10 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
# Emails configuration (optional)
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
@@ -71,6 +116,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# See translate.getoutline.com for a list of available language codes and their
# percentage translated.
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
+66
View File
@@ -0,0 +1,66 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
## Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
```
app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
├── scenes - A scene represents a full-page view that contains several components
├── stores - Collections of models and associated fetch logic
├── types - Flow types
└── utils - Utility methods specific to the frontend
```
## Backend
The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
```
server
├── api - All API routes are contained within here
│ └── middlewares - Koa middlewares specific to the API
├── auth - OAuth routes for Slack and Google, plus email authentication routes
├── commands - We are gradually moving to the command pattern for new write logic
├── config - Database configuration
├── emails - Transactional email templates
│ └── components - Shared React components for email templates
├── middlewares - Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
```
## Shared
Where logic is shared between the client and server it is placed in this directory. This is generally
small utilities.
```
shared
├── i18n - Internationalization confiuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
+73 -88
View File
@@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
## Installation
# Installation
Outline requires the following dependencies:
@@ -31,33 +31,58 @@ Outline requires the following dependencies:
- Slack or Google developer application for authentication
### Production
## Self-Hosted Production
For a manual self-hosted production installation these are the suggested steps:
### Docker
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
For a manual self-hosted production installation these are the recommended steps:
1. First setup Redis and Postgres servers, this is outside the scope of the guide.
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn sequelize:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn sequelize:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
1. (Optional) You can add an `nginx` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Terraform
Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
### Upgrading
#### Docker
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```shell
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Git
If you're running Outline by cloning this repository, run the following command to upgrade:
```shell
yarn run upgrade
```
### Development
## Local Development
In development you can quickly get an environment running using Docker by following these steps:
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
@@ -75,24 +100,29 @@ In development you can quickly get an environment running using Docker by follow
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Upgrade
#### Docker
# Contributing
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Yarn
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
If you're running Outline by cloning this repository, run the following command to upgrade:
```
yarn run upgrade
```
Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
## Development
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
### Server
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
@@ -100,52 +130,6 @@ Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging o
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
## Migrations
Sequelize is used to create and run migrations, for example:
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or to run migrations on test database:
```
yarn sequelize db:migrate --env test
```
## Structure
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
### Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
- `app/` - Frontend React application
- `app/scenes` - Full page views
- `app/components` - Reusable React components
- `app/stores` - Global state stores
- `app/models` - State models
- `app/types` - Flow types for non-models
### Backend
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
- `server/api` - API endpoints
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `server/models` - Database models
- `server/policies` - Authorization logic
- `server/presenters` - API responses for database models
- `server/test` - Test helps and support
- `server/utils` - Utility methods
- `shared` - Code shared between frontend and backend applications
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
@@ -171,20 +155,21 @@ yarn test:server
yarn test:app
```
## Contributing
## Migrations
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Sequelize is used to create and run migrations, for example:
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
Or to run migrations on test database:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
```
yarn sequelize db:migrate --env test
```
## License
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
Outline is [BSL 1.1 licensed](LICENSE).
+9 -3
View File
@@ -20,10 +20,11 @@ import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
};
|};
function Icon({ document }) {
const { t } = useTranslation();
@@ -79,10 +80,14 @@ function Icon({ document }) {
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return <Wrapper />;
}
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
@@ -140,6 +145,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</Crumb>
</>
)}
{children}
</Wrapper>
);
};
@@ -3,11 +3,15 @@ import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "shared/styles/animations";
type Props = {
type Props = {|
count: number,
};
|};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};
+1 -4
View File
@@ -76,7 +76,6 @@ class Layout extends React.Component<Props> {
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true;
}
@@ -86,7 +85,6 @@ class Layout extends React.Component<Props> {
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
@@ -94,7 +92,6 @@ class Layout extends React.Component<Props> {
@keydown("d")
goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl();
}
@@ -102,7 +99,7 @@ class Layout extends React.Component<Props> {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
-66
View File
@@ -1,66 +0,0 @@
// @flow
import BoundlessPopover from "boundless-popover";
import * as React from "react";
import styled, { keyframes } from "styled-components";
const fadeIn = keyframes`
from {
opacity: 0;
}
50% {
opacity: 1;
}
`;
const StyledPopover = styled(BoundlessPopover)`
animation: ${fadeIn} 150ms ease-in-out;
display: flex;
flex-direction: column;
line-height: 0;
position: absolute;
top: 0;
left: 0;
z-index: ${(props) => props.theme.depths.popover};
svg {
height: 16px;
width: 16px;
position: absolute;
polygon:first-child {
fill: rgba(0, 0, 0, 0.075);
}
polygon {
fill: #fff;
}
}
`;
const Dialog = styled.div`
outline: none;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px;
line-height: 1.5;
padding: 16px;
margin-top: 14px;
min-width: 200px;
min-height: 150px;
`;
export const Preset = BoundlessPopover.preset;
export default function Popover(props: Object) {
return (
<StyledPopover
dialogComponent={Dialog}
closeOnOutsideScroll
closeOnOutsideFocus
closeOnEscKey
{...props}
/>
);
}
+2 -4
View File
@@ -16,11 +16,11 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Bubble from "./components/Bubble";
import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
@@ -118,9 +118,7 @@ function MainSidebar() {
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
)}
<Bubble count={documents.totalDrafts} />
</Drafts>
}
active={
+125 -95
View File
@@ -3,29 +3,37 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
let BOUNCE_ANIMATION_MS = 250;
let ANIMATION_MS = 250;
type Props = {
children: React.Node,
location: Location,
};
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
function Sidebar({ children }: Props) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
@@ -38,24 +46,45 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[offset, maxWidth, setWidth]
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
const handleStopDrag = React.useCallback(
(event: MouseEvent) => {
setResizing(false);
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else {
setWidth(width);
}
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
if (document.activeElement) {
document.activeElement.blur();
}
const handleStartDrag = React.useCallback(
(event) => {
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
} else {
setWidth(width);
}
},
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
);
const handleMouseDown = React.useCallback(
(event: MouseEvent) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
@@ -65,10 +94,19 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
setTimeout(() => setAnimating(false), ANIMATION_MS);
}
}, [isAnimating]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
@@ -81,32 +119,6 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
};
}, [isResizing, handleDrag, handleStopDrag]);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
function Sidebar({ location, children }: Props) {
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const collapsed = ui.editMode || ui.sidebarCollapsed;
const {
isAnimating,
isSmallerThanMinimum,
isResizing,
handleStartDrag,
} = useResize({
width,
minWidth,
maxWidth,
setWidth: ui.setSidebarWidth,
});
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
@@ -124,49 +136,60 @@ function Sidebar({ location, children }: Props) {
const style = React.useMemo(
() => ({
width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
[width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
<>
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isCollapsing={isCollapsing}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
$isResizing={isResizing}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
{!ui.isEditing && (
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarCollapsed ? "right" : "left"}
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
/>
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
<ResizeHandle aria-label={t("Resize sidebar")} />
</ResizeBorder>
)}
</Container>
</>
);
// Fade in the sidebar on first render after page load
@@ -195,29 +218,36 @@ const Container = styled(Flex)`
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
left 100ms ease-out,
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
${Positioner} {
display: none;
}
@media print {
display: none;
left: 0;
transform: none;
}
${breakpoint("tablet")`
margin: 0;
z-index: 3;
min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover,
&:focus-within {
left: 0 !important;
transform: none;
box-shadow: ${(props) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
@@ -225,11 +255,11 @@ const Container = styled(Flex)`
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
& ${CollapseButton} {
opacity: .75;
${Positioner} {
display: block;
}
& ${CollapseButton}:hover {
${ToggleButton} {
opacity: 1;
}
}
@@ -241,4 +271,4 @@ const Container = styled(Flex)`
`};
`;
export default withRouter(observer(Sidebar));
export default observer(Sidebar);
@@ -1,59 +0,0 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} tabIndex="-1" aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;
@@ -42,8 +42,6 @@ class Collections extends React.Component<Props> {
@keydown("n")
goToNewDocument() {
if (this.props.ui.editMode) return;
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
@@ -1,6 +1,5 @@
// @flow
import styled from "styled-components";
import ResizeHandle from "./ResizeHandle";
const ResizeBorder = styled.div`
position: absolute;
@@ -9,20 +8,6 @@ const ResizeBorder = styled.div`
right: -6px;
width: 12px;
cursor: ew-resize;
${(props) =>
props.$isResizing &&
`
${ResizeHandle} {
opacity: 1;
}
`}
&:hover {
${ResizeHandle} {
opacity: 1;
}
}
`;
export default ResizeBorder;
@@ -1,39 +0,0 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;
@@ -0,0 +1,75 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
direction: "left" | "right",
style?: Object,
onClick?: () => any,
};
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
</ToggleButton>
</Positioner>
);
}
);
export const ToggleButton = styled.button`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: absolute;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${(props) => props.theme.divider};
&:active {
color: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
`}
`;
export const Positioner = styled.div`
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
`;
export default Toggle;
+16 -3
View File
@@ -13,17 +13,23 @@ type Props = {|
id?: string,
|};
function Switch({ width = 38, height = 20, label, ...props }: Props) {
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
const component = (
<Wrapper width={width} height={height}>
<HiddenInput type="checkbox" width={width} height={height} {...props} />
<HiddenInput
type="checkbox"
width={width}
height={height}
disabled={disabled}
{...props}
/>
<Slider width={width} height={height} />
</Wrapper>
);
if (label) {
return (
<Label htmlFor={props.id}>
<Label disabled={disabled} htmlFor={props.id}>
{component}
<LabelText>{label}</LabelText>
</Label>
@@ -36,6 +42,8 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
const Label = styled.label`
display: flex;
align-items: center;
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
`;
const Wrapper = styled.label`
@@ -79,6 +87,11 @@ const HiddenInput = styled.input`
height: 0;
visibility: hidden;
&:disabled + ${Slider} {
opacity: 0.75;
cursor: default;
}
&:checked + ${Slider} {
background-color: ${(props) => props.theme.primary};
}
+2 -2
View File
@@ -10,8 +10,8 @@ type Props = {
const StyledNavLink = styled(NavLink)`
position: relative;
display: inline-block;
display: inline-flex;
align-items: center;
font-weight: 500;
font-size: 14px;
color: ${(props) => props.theme.textTertiary};
+1
View File
@@ -8,6 +8,7 @@ const Tabs = styled.nav`
margin-bottom: 12px;
overflow-y: auto;
white-space: nowrap;
transition: opacity 250ms ease-out;
`;
export const Separator = styled.span`
+13 -10
View File
@@ -64,6 +64,10 @@ function CollectionMenu({
[history, collection.id]
);
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -83,20 +87,19 @@ function CollectionMenu({
try {
const file = files[0];
const document = await documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.url);
} catch (err) {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, ui, documents]
[history, ui, collection.id, documents]
);
const can = policies.abilities(collection.id);
@@ -108,7 +111,7 @@ function CollectionMenu({
type="file"
ref={file}
onChange={handleFilePicked}
onClick={(ev) => ev.stopPropagation()}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex="-1"
/>
@@ -146,7 +149,7 @@ function CollectionMenu({
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
title: `${t("Members")}`,
visible: can.update,
onClick: () => setShowCollectionMembers(true),
},
@@ -172,7 +175,7 @@ function CollectionMenu({
{renderModals && (
<>
<Modal
title={t("Collection permissions")}
title={t("Collection members")}
onRequestClose={() => setShowCollectionMembers(false)}
isOpen={showCollectionMembers}
>
+6 -4
View File
@@ -15,6 +15,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import {
documentHistoryUrl,
@@ -49,7 +50,8 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const { policies, collections, auth, ui } = useStores();
const team = useCurrentTeam();
const { policies, collections, ui } = useStores();
const menu = useMenuState({ modal });
const history = useHistory();
const { t } = useTranslation();
@@ -130,10 +132,10 @@ function DocumentMenu({
[document]
);
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && team.sharing);
const canViewHistory = can.read && !can.restore;
return (
<>
+2
View File
@@ -16,6 +16,7 @@ export default class Collection extends BaseModel {
icon: string;
color: string;
private: boolean;
sharing: boolean;
documents: NavigationNode[];
createdAt: ?string;
updatedAt: ?string;
@@ -112,6 +113,7 @@ export default class Collection extends BaseModel {
"name",
"color",
"description",
"sharing",
"icon",
"private",
"sort",
+1 -1
View File
@@ -230,7 +230,7 @@ class CollectionScene extends React.Component<Props> {
)}
</Wrapper>
<Modal
title={t("Collection permissions")}
title={t("Collection members")}
onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen}
>
+32 -2
View File
@@ -3,6 +3,7 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
@@ -17,6 +18,7 @@ import Switch from "components/Switch";
type Props = {
collection: Collection,
ui: UiStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
};
@@ -24,6 +26,7 @@ type Props = {
@observer
class CollectionEdit extends React.Component<Props> {
@observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
@observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@@ -44,6 +47,7 @@ class CollectionEdit extends React.Component<Props> {
icon: this.icon,
color: this.color,
private: this.private,
sharing: this.sharing,
sort: this.sort,
});
this.props.onSubmit();
@@ -82,8 +86,13 @@ class CollectionEdit extends React.Component<Props> {
this.private = ev.target.checked;
};
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
this.sharing = ev.target.checked;
};
render() {
const { t } = this.props;
const { auth, t } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
return (
<Flex column>
@@ -140,6 +149,25 @@ class CollectionEdit extends React.Component<Props> {
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing && teamSharingEnabled}
disabled={!teamSharingEnabled}
/>
<HelpText>
{teamSharingEnabled ? (
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
) : (
<Trans>
Public sharing is currently disabled in the team security
settings.
</Trans>
)}
</HelpText>
<Button
type="submit"
disabled={this.isSaving || !this.props.collection.name}
@@ -152,4 +180,6 @@ class CollectionEdit extends React.Component<Props> {
}
}
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit));
export default withTranslation()<CollectionEdit>(
inject("ui", "auth")(CollectionEdit)
);
+37 -11
View File
@@ -3,8 +3,9 @@ import { intersection } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
@@ -18,6 +19,7 @@ import Switch from "components/Switch";
type Props = {
history: RouterHistory,
auth: AuthStore,
ui: UiStore,
collections: CollectionsStore,
onSubmit: () => void,
@@ -30,6 +32,7 @@ class CollectionNew extends React.Component<Props> {
@observable description: string = "";
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@observable private: boolean = false;
@observable isSaving: boolean;
hasOpenedIconPicker: boolean = false;
@@ -41,6 +44,7 @@ class CollectionNew extends React.Component<Props> {
{
name: this.name,
description: this.description,
sharing: this.sharing,
icon: this.icon,
color: this.color,
private: this.private,
@@ -59,7 +63,7 @@ class CollectionNew extends React.Component<Props> {
}
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
handleNameChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.name = ev.target.value;
// If the user hasn't picked an icon yet, go ahead and suggest one based on
@@ -90,24 +94,31 @@ class CollectionNew extends React.Component<Props> {
this.description = getValue();
};
handlePrivateChange = (ev: SyntheticInputEvent<*>) => {
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.private = ev.target.checked;
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.sharing = ev.target.checked;
};
handleChange = (color: string, icon: string) => {
this.color = color;
this.icon = icon;
};
render() {
const { t } = this.props;
const { t, auth } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
{t(
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example."
)}
<Trans>
Collections are for grouping your knowledge base. They work best
when organized around a topic or internal team Product or
Engineering for example.
</Trans>
</HelpText>
<Flex>
<Input
@@ -142,10 +153,25 @@ class CollectionNew extends React.Component<Props> {
checked={this.private}
/>
<HelpText>
{t(
"A private collection will only be visible to invited team members."
)}
<Trans>
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
{teamSharingEnabled && (
<>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing}
/>
<HelpText>
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
</HelpText>
</>
)}
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? `${t("Creating")}` : t("Create")}
@@ -156,5 +182,5 @@ class CollectionNew extends React.Component<Props> {
}
export default withTranslation()<CollectionNew>(
inject("collections", "ui")(withRouter(CollectionNew))
inject("collections", "ui", "auth")(withRouter(CollectionNew))
);
+8 -15
View File
@@ -126,7 +126,7 @@ class Header extends React.Component<Props> {
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocuments = auth.team && auth.team.sharing && can.share;
const canShareDocument = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
const canEdit = can.update && !isEditing;
@@ -134,7 +134,6 @@ class Header extends React.Component<Props> {
<Actions
align="center"
justify="space-between"
readOnly={!isEditing}
isCompact={this.isScrolled}
shrink={false}
>
@@ -148,8 +147,7 @@ class Header extends React.Component<Props> {
onSubmit={this.handleCloseShareModal}
/>
</Modal>
<BreadcrumbAndContents align="center" justify="flex-start">
<Breadcrumb document={document} />
<Breadcrumb document={document}>
{!isEditing && (
<>
<Slash />
@@ -175,7 +173,7 @@ class Header extends React.Component<Props> {
</Tooltip>
</>
)}
</BreadcrumbAndContents>
</Breadcrumb>
{this.isScrolled && (
<Title onClick={this.handleClickTitle}>
<Fade>
@@ -202,7 +200,7 @@ class Header extends React.Component<Props> {
<TemplatesMenu document={document} />
</Action>
)}
{!isEditing && canShareDocuments && (
{!isEditing && canShareDocument && (
<Action>
<Tooltip
tooltip={
@@ -349,15 +347,6 @@ const Status = styled.div`
color: ${(props) => props.theme.slate};
`;
const BreadcrumbAndContents = styled(Flex)`
display: none;
${breakpoint("tablet")`
display: flex;
width: 33.3%;
`};
`;
const Wrapper = styled(Flex)`
width: 100%;
align-self: flex-end;
@@ -391,6 +380,10 @@ const Actions = styled(Flex)`
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
> div {
width: 33.3%;
}
`};
`;
+32 -17
View File
@@ -4,12 +4,13 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
@@ -27,12 +28,20 @@ type Props = {
users: UsersStore,
policies: PoliciesStore,
match: Match,
t: TFunction,
};
@observer
class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
@@ -46,7 +55,7 @@ class People extends React.Component<Props> {
};
render() {
const { auth, policies, match } = this.props;
const { auth, policies, match, t } = this.props;
const { filter } = match.params;
const currentUser = auth.user;
const team = auth.team;
@@ -65,15 +74,18 @@ class People extends React.Component<Props> {
}
const can = policies.abilities(team.id);
const { counts } = this.props.users;
return (
<CenteredContent>
<PageTitle title="People" />
<h1>People</h1>
<PageTitle title={t("People")} />
<h1>{t("People")}</h1>
<HelpText>
Everyone that has signed into Outline appears here. Its possible that
there are other users who have access through {team.signinMethods} but
havent signed in yet.
<Trans>
Everyone that has signed into Outline appears here. Its possible
that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText>
<Button
type="button"
@@ -84,37 +96,36 @@ class People extends React.Component<Props> {
icon={<PlusIcon />}
neutral
>
Invite people
{t("Invite people")}
</Button>
<Tabs>
<Tab to="/settings/people" exact>
Active
{t("Active")} <Bubble count={counts.active} />
</Tab>
<Tab to="/settings/people/admins" exact>
Admins
{t("Admins")} <Bubble count={counts.admins} />
</Tab>
{can.update && (
<Tab to="/settings/people/suspended" exact>
Suspended
{t("Suspended")} <Bubble count={counts.suspended} />
</Tab>
)}
<Tab to="/settings/people/all" exact>
Everyone
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab>
{can.invite && (
<>
<Separator />
<Tab to="/settings/people/invited" exact>
Invited
{t("Invited")} <Bubble count={counts.invited} />
</Tab>
</>
)}
</Tabs>
<PaginatedList
items={users}
empty={<Empty>No people to see here.</Empty>}
empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage}
renderItem={(item) => (
<UserListItem
@@ -126,7 +137,7 @@ class People extends React.Component<Props> {
/>
<Modal
title="Invite people"
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
@@ -137,4 +148,8 @@ class People extends React.Component<Props> {
}
}
export default inject("auth", "users", "policies")(People);
export default inject(
"auth",
"users",
"policies"
)(withTranslation()<People>(People));
+2 -2
View File
@@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
import type { PaginationParams } from "types";
import { client } from "utils/ApiClient";
type Action = "list" | "info" | "create" | "update" | "delete";
type Action = "list" | "info" | "create" | "update" | "delete" | "count";
function modelNameFromClassName(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
@@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
model: Class<T>;
modelName: string;
rootStore: RootStore;
actions: Action[] = ["list", "info", "create", "update", "delete"];
actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
constructor(rootStore: RootStore, model: Class<T>) {
this.rootStore = rootStore;
+20 -1
View File
@@ -1,6 +1,6 @@
// @flow
import { concat, filter, last } from "lodash";
import { computed } from "mobx";
import { computed, action } from "mobx";
import naturalSort from "shared/utils/naturalSort";
import Collection from "models/Collection";
@@ -88,6 +88,25 @@ export default class CollectionsStore extends BaseStore<Collection> {
});
}
@action
async update(params: Object): Promise<Collection> {
const result = await super.update(params);
// If we're changing sharing permissions on the collection then we need to
// remove all locally cached policies for documents in the collection as they
// are now invalid
if (params.sharing !== undefined) {
const collection = this.get(params.id);
if (collection) {
collection.documentIds.forEach((id) => {
this.rootStore.policies.remove(id);
});
}
}
return result;
}
getPathForDocument(documentId: string): ?DocumentPath {
return this.pathsToDocuments.find((path) => path.id === documentId);
}
+1
View File
@@ -22,6 +22,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@observable movingDocumentId: ?string;
importFileTypes: string[] = [
".md",
"text/markdown",
"text/plain",
"text/html",
+3 -3
View File
@@ -21,7 +21,7 @@ class UiStore {
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable isEditing: boolean = false;
@observable tocVisible: boolean = false;
@observable mobileSidebarVisible: boolean = false;
@observable sidebarWidth: number;
@@ -151,12 +151,12 @@ class UiStore {
@action
enableEditMode = () => {
this.editMode = true;
this.isEditing = true;
};
@action
disableEditMode = () => {
this.editMode = false;
this.isEditing = false;
};
@action
+42 -1
View File
@@ -1,13 +1,21 @@
// @flow
import invariant from "invariant";
import { filter, orderBy } from "lodash";
import { computed, action, runInAction } from "mobx";
import { observable, computed, action, runInAction } from "mobx";
import User from "models/User";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
import { client } from "utils/ApiClient";
export default class UsersStore extends BaseStore<User> {
@observable counts: {
active: number,
admins: number,
all: number,
invited: number,
suspended: number,
} = {};
constructor(rootStore: RootStore) {
super(rootStore, User);
}
@@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
@action
promote = (user: User) => {
this.counts.admins += 1;
return this.actionOnUser("promote", user);
};
@action
demote = (user: User) => {
this.counts.admins -= 1;
return this.actionOnUser("demote", user);
};
@action
suspend = (user: User) => {
this.counts.suspended += 1;
return this.actionOnUser("suspend", user);
};
@action
activate = (user: User) => {
this.counts.suspended -= 1;
return this.actionOnUser("activate", user);
};
@@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
invariant(res && res.data, "Data should be available");
runInAction(`invite`, () => {
res.data.users.forEach(this.add);
this.counts.invited += res.data.sent.length;
this.counts.all += res.data.sent.length;
});
return res.data;
};
@action
fetchCounts = async (teamId: string): Promise<*> => {
const res = await client.post(`/users.count`, { teamId });
invariant(res && res.data, "Data should be available");
this.counts = res.data.counts;
return res.data;
};
@action
async delete(user: User, options: Object = {}) {
super.delete(user, options);
if (!user.isSuspended && user.lastActiveAt) {
this.counts.active -= 1;
}
if (user.isInvited) {
this.counts.invited -= 1;
}
if (user.isAdmin) {
this.counts.admins -= 1;
}
if (user.isSuspended) {
this.counts.suspended -= 1;
}
this.counts.all -= 1;
}
notInCollection = (collectionId: string, query: string = "") => {
const memberships = filter(
this.rootStore.memberships.orderedData,
+7 -1
View File
@@ -1,6 +1,7 @@
// @flow
import invariant from "invariant";
import { map, trim } from "lodash";
import { getCookie } from "tiny-cookie";
import stores from "stores";
import download from "./download";
import {
@@ -18,6 +19,11 @@ type Options = {
baseUrl?: string,
};
// authorization cookie set by a Cloudflare Access proxy
const CF_AUTHORIZATION = getCookie("CF_Authorization");
// if the cookie is set, we must pass it with all ApiClient requests
const CREDENTIALS = CF_AUTHORIZATION ? "same-origin" : "omit";
class ApiClient {
baseUrl: string;
userAgent: string;
@@ -91,7 +97,7 @@ class ApiClient {
body,
headers,
redirect: "follow",
credentials: "omit",
credentials: CREDENTIALS,
cache: "no-cache",
});
} catch (err) {
-58
View File
@@ -1,58 +0,0 @@
// flow-typed signature: 81720de1e8cfea1529815ce45326fdff
// flow-typed version: <<STUB>>/boundless-popover_v^1.0.4/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'boundless-popover'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'boundless-popover' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'boundless-popover/build' {
declare module.exports: any;
}
declare module 'boundless-popover/demo' {
declare module.exports: any;
}
declare module 'boundless-popover/index.spec' {
declare module.exports: any;
}
// Filename aliases
declare module 'boundless-popover/build/index' {
declare module.exports: $Exports<'boundless-popover/build'>;
}
declare module 'boundless-popover/build/index.js' {
declare module.exports: $Exports<'boundless-popover/build'>;
}
declare module 'boundless-popover/demo/index' {
declare module.exports: $Exports<'boundless-popover/demo'>;
}
declare module 'boundless-popover/demo/index.js' {
declare module.exports: $Exports<'boundless-popover/demo'>;
}
declare module 'boundless-popover/index' {
declare module.exports: $Exports<'boundless-popover'>;
}
declare module 'boundless-popover/index.js' {
declare module.exports: $Exports<'boundless-popover'>;
}
declare module 'boundless-popover/index.spec.js' {
declare module.exports: $Exports<'boundless-popover/index.spec'>;
}
+2 -3
View File
@@ -78,7 +78,6 @@
"babel-plugin-styled-components": "^1.11.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"boundless-arrow-key-navigation": "^1.0.4",
"boundless-popover": "^1.0.4",
"bull": "^3.5.2",
"cancan": "3.1.0",
"compressorjs": "^1.0.7",
@@ -154,7 +153,7 @@
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",
"reakit": "^1.3.4",
"rich-markdown-editor": "^11.1.6",
"rich-markdown-editor": "^11.2.0-0",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -212,4 +211,4 @@
"js-yaml": "^3.13.1"
},
"version": "0.52.0"
}
}
+6 -1
View File
@@ -34,6 +34,7 @@ router.post("collections.create", auth(), async (ctx) => {
name,
color,
description,
sharing,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
@@ -55,6 +56,7 @@ router.post("collections.create", auth(), async (ctx) => {
teamId: user.teamId,
createdById: user.id,
private: isPrivate,
sharing,
sort,
});
@@ -452,7 +454,7 @@ router.post("collections.export_all", auth(), async (ctx) => {
});
router.post("collections.update", auth(), async (ctx) => {
let { id, name, description, icon, color, sort } = ctx.body;
let { id, name, description, icon, color, sort, sharing } = ctx.body;
const isPrivate = ctx.body.private;
if (color) {
@@ -498,6 +500,9 @@ router.post("collections.update", auth(), async (ctx) => {
if (isPrivate !== undefined) {
collection.private = isPrivate;
}
if (sharing !== undefined) {
collection.sharing = sharing;
}
if (sort !== undefined) {
collection.sort = sort;
}
+12
View File
@@ -876,6 +876,18 @@ describe("#collections.create", () => {
expect(body.policies[0].abilities.export).toBeTruthy();
});
it("should allow setting sharing to false", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {
body: { token: user.getJwtToken(), name: "Test", sharing: false },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.sharing).toBe(false);
});
it("should return correct policies with private collection", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {
+5
View File
@@ -488,6 +488,11 @@ async function loadDocument({ id, shareId, user }) {
authorize(user, "read", document);
}
const collection = await Collection.findByPk(document.collectionId);
if (!collection.sharing) {
throw new AuthorizationError();
}
const team = await Team.findByPk(document.teamId);
if (!team.sharing) {
throw new AuthorizationError();
+17
View File
@@ -112,6 +112,23 @@ describe("#documents.info", () => {
expect(res.status).toEqual(403);
});
it("should not return document from shareId if sharing is disabled for collection", async () => {
const { document, collection, user } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
});
collection.sharing = false;
await collection.save();
const res = await server.post("/api/documents.info", {
body: { shareId: share.id },
});
expect(res.status).toEqual(403);
});
it("should not return document from revoked shareId", async () => {
const { document, user } = await seed();
const share = await buildShare({
+45 -13
View File
@@ -2,7 +2,7 @@
import Router from "koa-router";
import Sequelize from "sequelize";
import auth from "../middlewares/authentication";
import { Event, Team, User } from "../models";
import { Event, Team, User, Collection } from "../models";
import policy from "../policies";
import { presentEvent } from "../presenters";
import pagination from "./middlewares/pagination";
@@ -12,30 +12,62 @@ const { authorize } = policy;
const router = new Router();
router.post("events.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, auditLog = false } = ctx.body;
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const collectionIds = await user.collectionIds({ paranoid: false });
let {
sort = "createdAt",
actorId,
collectionId,
direction,
name,
auditLog = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC";
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = {
...where,
actorId,
};
}
if (collectionId) {
ctx.assertUuid(collectionId, "collection must be a UUID");
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
} else {
const collectionIds = await user.collectionIds({ paranoid: false });
where = {
...where,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
}
if (auditLog) {
authorize(user, "auditLog", Team);
where.name = Event.AUDIT_EVENTS;
}
if (name && where.name.includes(name)) {
where.name = name;
}
const events = await Event.findAll({
where,
order: [[sort, direction]],
+105 -1
View File
@@ -13,7 +13,7 @@ describe("#events.list", () => {
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// private event
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
@@ -29,6 +29,7 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken() },
});
@@ -39,6 +40,100 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should return audit events", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].id).toEqual(event.id);
expect(body.data[1].id).toEqual(auditEvent.id);
});
it("should allow filtering by actorId", async () => {
const { user, admin, document, collection } = await seed();
// audit event
const auditEvent = await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
name: "documents.publish",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return events with deleted actors", async () => {
const { user, admin, document, collection } = await seed();
@@ -64,6 +159,15 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should require authorization for audit events", async () => {
const { user } = await seed();
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken(), auditLog: true },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/events.list");
const body = await res.json();
+10 -1
View File
@@ -202,7 +202,7 @@ describe("#shares.create", () => {
expect(body.data.id).toBe(share.id);
});
it("should not allow creating a share record if disabled", async () => {
it("should not allow creating a share record if team sharing disabled", async () => {
const { user, document, team } = await seed();
await team.update({ sharing: false });
const res = await server.post("/api/shares.create", {
@@ -211,6 +211,15 @@ describe("#shares.create", () => {
expect(res.status).toEqual(403);
});
it("should not allow creating a share record if collection sharing disabled", async () => {
const { user, collection, document } = await seed();
await collection.update({ sharing: false });
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/shares.create", {
+11
View File
@@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
};
});
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
},
};
});
router.post("users.info", auth(), async (ctx) => {
ctx.body = {
data: presentUser(ctx.state.user),
+74 -1
View File
@@ -2,7 +2,8 @@
import TestServer from "fetch-test-server";
import app from "../app";
import { buildUser } from "../test/factories";
import { buildTeam, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -353,3 +354,75 @@ describe("#users.activate", () => {
expect(body).toMatchSnapshot();
});
});
describe("#users.count", () => {
it("should count active users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(1);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count suspended users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildUser({ teamId: team.id, suspendedAt: new Date() });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(2);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(1);
expect(body.data.counts.active).toEqual(1);
});
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(1);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.count");
expect(res.status).toEqual(401);
});
});
@@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'sharing', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'sharing');
}
}
@@ -0,0 +1,101 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// dropping because 100% unused and large according to stats
await queryInterface.removeIndex("documents", ["collaboratorIds"]);
// dropping because redundant, primary id index exists
await queryInterface.removeIndex("documents", ["id", "deletedAt"]);
// dropping because redundant, unique urlId index exists
await queryInterface.removeIndex("documents", ["urlId", "deletedAt"]);
// dropping to recreate as partial index
await queryInterface.removeIndex("documents", [
"parentDocumentId",
"atlasId",
"deletedAt",
]);
// dropping to recreate as partial index
await queryInterface.removeIndex("documents", ["archivedAt"]);
// dropping because pointless, all of these have "id" at the beginning so
// if we"re querying by an id why would you need to index subsequent columns
await queryInterface.removeIndex("documents", ["id", "atlasId", "publishedAt"]);
await queryInterface.removeIndex("documents", ["id", "atlasId", "deletedAt"]);
await queryInterface.removeIndex("documents", ["id", "teamId", "deletedAt"]);
// create new document indexes
await queryInterface.addIndex("documents", ["archivedAt"], {
concurrently: true,
where: {
archivedAt: {
[Sequelize.Op.ne]: null,
}
}
});
await queryInterface.addIndex("documents", ["deletedAt"], {
concurrently: true,
where: {
deletedAt: {
[Sequelize.Op.ne]: null,
}
}
});
await queryInterface.addIndex("documents", ["publishedAt"], {
concurrently: true,
where: {
publishedAt: {
[Sequelize.Op.ne]: null,
}
}
});
await queryInterface.addIndex("documents", ["teamId"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["collectionId"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["parentDocumentId"], {
concurrently: true
});
},
down: async (queryInterface, Sequelize) => {
// remove newer indexes
await queryInterface.removeIndex("documents", ["archivedAt"]);
await queryInterface.removeIndex("documents", ["deletedAt"]);
await queryInterface.removeIndex("documents", ["publishedAt"]);
await queryInterface.removeIndex("documents", ["teamId"]);
await queryInterface.removeIndex("documents", ["collectionId"]);
await queryInterface.removeIndex("documents", ["parentDocumentId"]);
// restore old indexes with old names
await queryInterface.addIndex("documents", ["collaboratorIds"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["id", "deletedAt"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["urlId", "deletedAt"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["id", "collectionId", "publishedAt"], {
name: "documents_id_atlas_id_published_at",
concurrently: true
});
await queryInterface.addIndex("documents", ["id", "collectionId", "deletedAt"], {
name: "documents_id_atlas_id_deleted_at",
concurrently: true
});
await queryInterface.addIndex("documents", ["id", "teamId", "deletedAt"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["id", "teamId", "deletedAt"], {
concurrently: true
});
await queryInterface.addIndex("documents", ["archivedAt"], {
concurrently: true
});
},
};
@@ -0,0 +1,36 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// dropping because redundant, contains id
await queryInterface.removeIndex("collections", "atlases_id_deleted_at");
await queryInterface.removeIndex("collections", "atlases_id_team_id_deleted_at");
// create new collection indexes
await queryInterface.addIndex("collections", ["teamId"], {
concurrently: true,
});
await queryInterface.addIndex("collections", ["deletedAt"], {
concurrently: true,
where: {
deletedAt: {
[Sequelize.Op.ne]: null,
}
}
});
},
down: async (queryInterface, Sequelize) => {
// remove newer indexes
await queryInterface.removeIndex("collections", ["teamId"]);
await queryInterface.removeIndex("collections", ["deletedAt"]);
// restore old indexes with old names
await queryInterface.addIndex("collections", ["id", "deletedAt"], {
name: "atlases_id_deleted_at",
concurrently: true
});
await queryInterface.addIndex("collections", ["id", "teamId", "deletedAt"], {
name: "atlases_id_team_id_deleted_at",
concurrently: true
});
}
}
+5
View File
@@ -24,6 +24,11 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB,
sharing: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
sort: {
type: DataTypes.JSONB,
validate: {
+2
View File
@@ -62,6 +62,7 @@ Event.ACTIVITY_EVENTS = [
"documents.unarchive",
"documents.pin",
"documents.unpin",
"documents.move",
"documents.delete",
"documents.restore",
"users.create",
@@ -86,6 +87,7 @@ Event.AUDIT_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.restore",
"groups.create",
"groups.update",
"groups.delete",
+32
View File
@@ -51,6 +51,9 @@ const User = sequelize.define(
isSuspended() {
return !!this.suspendedAt;
},
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() {
const original = this.getDataValue("avatarUrl");
if (original) {
@@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
]);
});
User.getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const results = await sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts = results[0];
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
export default User;
+23
View File
@@ -31,6 +31,29 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
return true;
});
allow(User, "share", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
if (collection.private) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);
}
return true;
});
allow(User, ["publish", "update"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
+13 -3
View File
@@ -31,12 +31,22 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, ["update", "share"], Document, (user, document) => {
allow(User, "share", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
// existence of collection option is not required here to account for share tokens
if (document.collection && cannot(user, "update", document.collection)) {
if (cannot(user, "share", document.collection)) {
return false;
}
return user.teamId === document.teamId;
});
allow(User, "update", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (cannot(user, "update", document.collection)) {
return false;
}
+1
View File
@@ -30,6 +30,7 @@ export default function present(collection: Collection) {
icon: collection.icon,
color: collection.color || "#4E5C6E",
private: collection.private,
sharing: collection.sharing,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,
+2 -2
View File
@@ -9,10 +9,10 @@
<link
rel="shortcut icon"
type="image/png"
href="favicon-32.png"
href="/favicon-32.png"
sizes="32x32"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link
rel="search"
type="application/opensearchdescription+xml"
+13 -6
View File
@@ -86,8 +86,6 @@
"Change Language": "Change Language",
"Dismiss": "Dismiss",
"Keyboard shortcuts": "Keyboard shortcuts",
"Expand": "Expand",
"Collapse": "Collapse",
"New collection": "New collection",
"Collections": "Collections",
"Untitled": "Untitled",
@@ -110,7 +108,8 @@
"Export Data": "Export Data",
"Integrations": "Integrations",
"Installation": "Installation",
"Resize sidebar": "Resize sidebar",
"Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar",
"Star": "Star",
"Appearance": "Appearance",
@@ -131,10 +130,9 @@
"New document": "New document",
"Import document": "Import document",
"Edit": "Edit",
"Permissions": "Permissions",
"Export": "Export",
"Delete": "Delete",
"Collection permissions": "Collection permissions",
"Collection members": "Collection members",
"Edit collection": "Edit collection",
"Delete collection": "Delete collection",
"Export collection": "Export collection",
@@ -211,6 +209,9 @@
"Alphabetical": "Alphabetical",
"Private collection": "Private collection",
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
"Public document sharing": "Public document sharing",
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Saving": "Saving",
"Save": "Save",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
@@ -231,6 +232,7 @@
"No people left to add": "No people left to add",
"Read only": "Read only",
"Read & Edit": "Read & Edit",
"Permissions": "Permissions",
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"Invited": "Invited",
@@ -304,6 +306,12 @@
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1></1>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?",
"Clear filters": "Clear filters",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",
@@ -321,7 +329,6 @@
"You joined": "You joined",
"Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.",
"Suspended": "Suspended",
"Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
}
-38
View File
@@ -2702,44 +2702,11 @@ boundless-arrow-key-navigation@^1.0.4:
boundless-utils-omit-keys "^1.1.0"
boundless-utils-uuid "^1.1.0"
boundless-dialog@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-dialog/-/boundless-dialog-1.1.0.tgz#31e97a17c5eb1e2aa9068066a752720aa04d5df2"
integrity sha1-Mel6F8XrHiqpBoBmp1JyCqBNXfI=
dependencies:
boundless-portal "^1.1.0"
boundless-utils-omit-keys "^1.1.0"
classnames "^2.1.5"
boundless-popover@^1.0.4:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-popover/-/boundless-popover-1.1.0.tgz#1c4d8aa045486a34aa3eaf9fdccccfa7aed478cf"
integrity sha1-HE2KoEVIajSqPq+f3MzPp67UeM8=
dependencies:
boundless-dialog "^1.1.0"
boundless-portal "^1.1.0"
boundless-utils-omit-keys "^1.1.0"
boundless-utils-transform-property "^1.1.0"
classnames "^2.1.5"
boundless-portal@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-portal/-/boundless-portal-1.1.0.tgz#cbf990f7f9a03cecf44736bffdb4d780f6c9e8c1"
integrity sha1-y/mQ9/mgPOz0Rza//bTXgPbJ6ME=
dependencies:
boundless-utils-omit-keys "^1.1.0"
boundless-utils-uuid "^1.1.0"
boundless-utils-omit-keys@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-utils-omit-keys/-/boundless-utils-omit-keys-1.1.0.tgz#fae73cdb90c113d56201d0b62e8f1143e0d193be"
integrity sha1-+uc825DBE9ViAdC2Lo8RQ+DRk74=
boundless-utils-transform-property@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-utils-transform-property/-/boundless-utils-transform-property-1.1.0.tgz#95da14d79d8dd05fe7bfe6d9636b8b8d1ec7565d"
integrity sha1-ldoU152N0F/nv+bZY2uLjR7HVl0=
boundless-utils-uuid@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/boundless-utils-uuid/-/boundless-utils-uuid-1.1.0.tgz#ae709f1d4fd3a4557ad4a5c77b1f0a9f701e3ed3"
@@ -3296,11 +3263,6 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@^2.1.5:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
clean-css@4.2.x, clean-css@^4.0.12:
version "4.2.3"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"