You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-23 23:33:01 +02:00
Compare commits
43 Commits
plugin_tas
...
server_adm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d3bcdcb0e | ||
|
|
3fcdeb08d9 | ||
|
|
41b6011769 | ||
|
|
d71eaa1a02 | ||
|
|
6e0ed87190 | ||
|
|
ebaddfa4a8 | ||
|
|
2ab049305e | ||
|
|
1ddf01f8b8 | ||
|
|
bc4dbe1300 | ||
|
|
c6900354f2 | ||
|
|
98bfb65ead | ||
|
|
09cbe3cb7c | ||
|
|
fd322edaab | ||
|
|
09ed92bc26 | ||
|
|
fb6069de6d | ||
|
|
39056ae1aa | ||
|
|
d031a04a2c | ||
|
|
ed0f0fae01 | ||
|
|
c185f00006 | ||
|
|
3117133be2 | ||
|
|
b92c650f5d | ||
|
|
9dbf5e02eb | ||
|
|
e1016b8af4 | ||
|
|
3ba4a1de72 | ||
|
|
a683f12622 | ||
|
|
89184a3f9f | ||
|
|
24dbede6c1 | ||
|
|
70e623e741 | ||
|
|
5c77317735 | ||
|
|
9684b38f7e | ||
|
|
3dfe43204d | ||
|
|
16148b2255 | ||
|
|
df14ffdee2 | ||
|
|
f6ed5eb064 | ||
|
|
a14115bfd7 | ||
|
|
dd68d6cf2c | ||
|
|
7d3555d62c | ||
|
|
af472528a2 | ||
|
|
5c8b0ec1cb | ||
|
|
5e20e890d8 | ||
|
|
50dc656f65 | ||
|
|
b36cf46a06 | ||
|
|
1781334374 |
@@ -1,12 +1,19 @@
|
||||
_mydocs/
|
||||
_releases/
|
||||
.git/
|
||||
.yarn/cache/
|
||||
**/.DS_Store
|
||||
**/node_modules
|
||||
Assets/
|
||||
.git/
|
||||
_releases/
|
||||
packages/app-desktop
|
||||
packages/app-cli
|
||||
packages/app-mobile
|
||||
packages/app-clipper
|
||||
packages/generator-joplin
|
||||
packages/plugin-repo-cli
|
||||
docs/
|
||||
lerna-debug.log
|
||||
packages/app-cli/
|
||||
packages/app-clipper/
|
||||
packages/app-desktop/
|
||||
packages/app-mobile/
|
||||
packages/generator-joplin/
|
||||
packages/plugin-repo-cli/
|
||||
packages/server/db-*.sqlite
|
||||
packages/server/temp
|
||||
packages/server/dist/
|
||||
packages/server/logs/
|
||||
packages/server/temp/
|
||||
|
||||
@@ -52,8 +52,6 @@ packages/app-mobile/lib/rnInjectedJs/
|
||||
packages/app-mobile/locales
|
||||
packages/app-mobile/node_modules
|
||||
packages/app-mobile/pluginAssets/
|
||||
packages/electron-process-manager/dist/
|
||||
packages/electron-process-manager/src/ui/
|
||||
packages/fork-*
|
||||
packages/htmlpack/dist/
|
||||
packages/lib/assets/
|
||||
|
||||
5
.github/scripts/run_ci.sh
vendored
5
.github/scripts/run_ci.sh
vendored
@@ -1,10 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# We want the script to stop as soon as an error is found. Otherwise if there's
|
||||
# an error during `yarn install` for example, it's also going to throw millions
|
||||
# of errors in test units, which makes debugging difficult.
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# Setup environment variables
|
||||
# =============================================================================
|
||||
|
||||
25
.github/stale.yml
vendored
25
.github/stale.yml
vendored
@@ -1,25 +0,0 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 30
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- "good first issue"
|
||||
- "upstream"
|
||||
- "backlog"
|
||||
- "high"
|
||||
- "medium"
|
||||
- "spec"
|
||||
- "cannot reproduce"
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs.
|
||||
You may comment on the issue and I will leave it open.
|
||||
Thank you for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: >
|
||||
Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please feel free to create a new issue with up-to-date information.
|
||||
only: issues
|
||||
23
.github/workflows/close-stale-issues.yml
vendored
Normal file
23
.github/workflows/close-stale-issues.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: 'Close stale issues'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 16 * * *'
|
||||
permissions:
|
||||
issues: write
|
||||
jobs:
|
||||
ProcessStaleIssues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
# Use this to do a dry run from a pull request
|
||||
# debug-only: true
|
||||
stale-issue-message: "Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs. You may comment on the issue and I will leave it open. Thank you for your contributions."
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
operations-per-run: 1000
|
||||
exempt-issue-labels: 'good first issue,upstream,backlog,high,medium,spec,cannot reproduce'
|
||||
stale-issue-label: 'stale'
|
||||
close-issue-message: 'Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, feel free to create a new issue with up-to-date information.'
|
||||
# Don't process pull requests at all
|
||||
days-before-pr-stale: -1
|
||||
43
.yarn/patches/markdown-it-multimd-table-npm-4.1.1-47e334d4bd
Normal file
43
.yarn/patches/markdown-it-multimd-table-npm-4.1.1-47e334d4bd
Normal file
@@ -0,0 +1,43 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 85d89900d5fe575dd0c19430209fb1703b03554e..5fa68cc9a4bd2b21a7188bd263262fd9b1604ac6 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -145,7 +145,8 @@ module.exports = function multimd_table_plugin(md, options) {
|
||||
colspan, leftToken,
|
||||
rowspan, upTokens = [],
|
||||
tableLines, tgroupLines,
|
||||
- tag, text, range, r, c, b;
|
||||
+ tag, text, range, r, c, b, t,
|
||||
+ blockState;
|
||||
|
||||
if (startLine + 2 > endLine) { return false; }
|
||||
|
||||
@@ -315,18 +316,26 @@ module.exports = function multimd_table_plugin(md, options) {
|
||||
|
||||
/* Multiline. Join the text and feed into markdown-it blockParser. */
|
||||
if (options.multiline && trToken.meta.multiline && trToken.meta.mbounds) {
|
||||
- text = [ text.trimRight() ];
|
||||
+ // Pad the text with empty lines to ensure the line number mapping is correct
|
||||
+ text = new Array(trToken.map[0]).fill('').concat([ text.trimRight() ]);
|
||||
for (b = 1; b < trToken.meta.mbounds.length; b++) {
|
||||
/* Line with N bounds has cells indexed from 0 to N-2 */
|
||||
if (c > trToken.meta.mbounds[b].length - 2) { continue; }
|
||||
range = [ trToken.meta.mbounds[b][c] + 1, trToken.meta.mbounds[b][c + 1] ];
|
||||
text.push(state.src.slice.apply(state.src, range).trimRight());
|
||||
}
|
||||
- state.md.block.parse(text.join('\n'), state.md, state.env, state.tokens);
|
||||
+ blockState = new state.md.block.State(text.join('\n'), state.md, state.env, []);
|
||||
+ blockState.level = trToken.level + 1;
|
||||
+ // Start tokenizing from the actual content (trToken.map[0])
|
||||
+ state.md.block.tokenize(blockState, trToken.map[0], blockState.lineMax);
|
||||
+ for (t = 0; t < blockState.tokens.length; t++) {
|
||||
+ state.tokens.push(blockState.tokens[t]);
|
||||
+ }
|
||||
} else {
|
||||
token = state.push('inline', '', 0);
|
||||
token.content = text.trim();
|
||||
token.map = trToken.map;
|
||||
+ token.level = trToken.level + 1;
|
||||
token.children = [];
|
||||
}
|
||||
|
||||
@@ -728,6 +728,23 @@ footer .bottom-links-row p {
|
||||
}
|
||||
}
|
||||
|
||||
/*****************************************************************
|
||||
MEDIUM VIEW
|
||||
- Make menu bar elements smaller and closer to each others
|
||||
so that everything fit.
|
||||
*****************************************************************/
|
||||
|
||||
@media (max-width: 990px) {
|
||||
#nav-section > .container {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
#nav-section .button-link {
|
||||
padding: 4px 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/*****************************************************************
|
||||
NARROW VIEW
|
||||
- Top right menu is displayed
|
||||
@@ -740,6 +757,23 @@ footer .bottom-links-row p {
|
||||
padding-bottom: 130px;
|
||||
}
|
||||
|
||||
#menu-mobile .social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#menu-mobile .social-links a {
|
||||
margin-left: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#menu-mobile .social-links .social-link-mastodon,
|
||||
#menu-mobile .social-links .social-link-reddit,
|
||||
#menu-mobile .social-links .social-link-patreon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.front-page h1 {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
@@ -857,7 +891,7 @@ footer .bottom-links-row p {
|
||||
}
|
||||
|
||||
#menu-mobile .button-link {
|
||||
padding: 10px 15px;
|
||||
padding: 4px 12px;
|
||||
font-size: 16px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<footer class="darkblue-bg">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-12 social-links">
|
||||
<a href="https://twitter.com/joplinapp" title="Joplin Twitter feed"><i class="fab fa-twitter"></i></a>
|
||||
<a href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
|
||||
<a href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
|
||||
<a href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
|
||||
<a href="https://www.reddit.com/r/joplinapp/" title="Joplin Subreddit"><i class="fab fa-reddit"></i></a>
|
||||
<a href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
{{> socialFeeds}}
|
||||
|
||||
<div class="row bottom-links-row">
|
||||
<div class="col-12 col-md-6">
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-9 text-right d-none d-md-block">
|
||||
<a href="{{baseUrl}}/news/" class="fw500">What's New</a>
|
||||
<a href="https://twitter.com/joplinapp" title="Joplin Twitter feed" class="fw500"><i class="fab fa-twitter"></i></a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500">Forum</a>
|
||||
{{#showJoplinCloudLinks}}
|
||||
@@ -43,7 +44,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center menu-mobile-top">
|
||||
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">What's New</a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
|
||||
</div>
|
||||
@@ -59,6 +60,8 @@
|
||||
{{#showToc}}
|
||||
<div id="toc-mobile">{{{tocHtml}}}</div>
|
||||
{{/showToc}}
|
||||
|
||||
{{> socialFeeds}}
|
||||
|
||||
<div>
|
||||
<p class="light-blue mobile-menu-link-bottom text-center">
|
||||
|
||||
10
Assets/WebsiteAssets/templates/partials/socialFeeds.mustache
Normal file
10
Assets/WebsiteAssets/templates/partials/socialFeeds.mustache
Normal file
@@ -0,0 +1,10 @@
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-12 social-links">
|
||||
<a class="social-link-twitter" href="https://twitter.com/joplinapp" title="Joplin Twitter feed"><i class="fab fa-twitter"></i></a>
|
||||
<a class="social-link-mastodon" href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
|
||||
<a class="social-link-patreon" href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
|
||||
<a class="social-link-discord" href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
|
||||
<a class="social-link-reddit" href="https://www.reddit.com/r/joplinapp/" title="Joplin Subreddit"><i class="fab fa-reddit"></i></a>
|
||||
<a class="social-link-github" href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
2
BUILD.md
2
BUILD.md
@@ -20,7 +20,7 @@ There are also a few forks of existing packages under the "fork-*" name.
|
||||
|
||||
- Install node 16+ - https://nodejs.org/en/
|
||||
- [Enable yarn](https://yarnpkg.com/getting-started/install): `corepack enable`
|
||||
- macOS: Install Cocoapods - `brew install cocoapods`
|
||||
- macOS: Install Cocoapods - `brew install cocoapods`. Apple Silicon [may require libvips](https://github.com/laurent22/joplin/pull/5966#issuecomment-1007158597) - `brew install vips`.
|
||||
- Windows: Install Windows Build Tools - `yarn install -g windows-build-tools --vs2015`
|
||||
- Linux: Install dependencies - `sudo apt install build-essential libnss3 libsecret-1-dev python rsync`
|
||||
|
||||
|
||||
@@ -8,66 +8,36 @@ RUN apt-get update \
|
||||
# Enables Yarn
|
||||
RUN corepack enable
|
||||
|
||||
RUN echo "Node: $(node --version)"
|
||||
RUN echo "Npm: $(npm --version)"
|
||||
RUN echo "Yarn: $(yarn --version)"
|
||||
RUN echo "Node: $(node --version)" \
|
||||
&& echo "Npm: $(npm --version)" \
|
||||
&& echo "Yarn: $(yarn --version)"
|
||||
|
||||
ARG user=joplin
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
USER $user
|
||||
|
||||
ENV NODE_ENV development
|
||||
ENV NODE_ENV production
|
||||
ENV RUNNING_IN_DOCKER 1
|
||||
EXPOSE ${APP_PORT}
|
||||
|
||||
WORKDIR /home/$user
|
||||
|
||||
RUN mkdir /home/$user/logs
|
||||
RUN mkdir /home/$user/logs \
|
||||
&& mkdir /home/$user/.yarn
|
||||
|
||||
# Install the root scripts but don't run postinstall (which would bootstrap
|
||||
# and build TypeScript files, but we don't have the TypeScript files at
|
||||
# this point)
|
||||
|
||||
COPY --chown=$user:$user package*.json ./
|
||||
COPY --chown=$user:$user .yarn ./.yarn
|
||||
COPY --chown=$user:$user .yarn/patches ./.yarn/patches
|
||||
COPY --chown=$user:$user .yarn/plugins ./.yarn/plugins
|
||||
COPY --chown=$user:$user .yarn/releases ./.yarn/releases
|
||||
COPY --chown=$user:$user package.json .
|
||||
COPY --chown=$user:$user .yarnrc.yml .
|
||||
COPY --chown=$user:$user yarn.lock .
|
||||
COPY --chown=$user:$user gulpfile.js .
|
||||
|
||||
RUN yarn install --inline-builds --mode=skip-build
|
||||
|
||||
# To take advantage of the Docker cache, we first copy all the package.json
|
||||
# and package-lock.json files, as they rarely change, and then bootstrap
|
||||
# all the packages.
|
||||
#
|
||||
# Note that bootstrapping the packages will run all the postinstall
|
||||
# scripts, which means that for packages that have such scripts, we need to
|
||||
# copy all the files.
|
||||
#
|
||||
# We can't run boostrap with "--ignore-scripts" because that would
|
||||
# prevent certain sub-packages, such as sqlite3, from being built
|
||||
|
||||
COPY --chown=$user:$user packages/fork-sax/package*.json ./packages/fork-sax/
|
||||
COPY --chown=$user:$user packages/fork-uslug/package*.json ./packages/fork-uslug/
|
||||
COPY --chown=$user:$user packages/htmlpack/package*.json ./packages/htmlpack/
|
||||
COPY --chown=$user:$user packages/renderer/package*.json ./packages/renderer/
|
||||
COPY --chown=$user:$user packages/tools/package*.json ./packages/tools/
|
||||
COPY --chown=$user:$user packages/lib/package*.json ./packages/lib/
|
||||
COPY --chown=$user:$user tsconfig.json .
|
||||
|
||||
# The following have postinstall scripts so we need to copy all the files.
|
||||
# Since they should rarely change this is not an issue
|
||||
|
||||
COPY --chown=$user:$user packages/turndown ./packages/turndown
|
||||
COPY --chown=$user:$user packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
|
||||
COPY --chown=$user:$user packages/fork-htmlparser2 ./packages/fork-htmlparser2
|
||||
COPY --chown=$user:$user packages/server/package*.json ./packages/server/
|
||||
|
||||
# Then bootstrap only, without compiling the TypeScript files
|
||||
|
||||
RUN yarn install --inline-builds --mode=skip-build
|
||||
|
||||
# Now copy the source files. Put lib and server last as they are more likely to change.
|
||||
|
||||
COPY --chown=$user:$user packages/fork-sax ./packages/fork-sax
|
||||
COPY --chown=$user:$user packages/fork-uslug ./packages/fork-uslug
|
||||
COPY --chown=$user:$user packages/htmlpack ./packages/htmlpack
|
||||
@@ -76,21 +46,23 @@ COPY --chown=$user:$user packages/tools ./packages/tools
|
||||
COPY --chown=$user:$user packages/lib ./packages/lib
|
||||
COPY --chown=$user:$user packages/server ./packages/server
|
||||
|
||||
# Finally build everything, in particular the TypeScript files. We can't just
|
||||
# run `yarn run build` because that wouldn't run the postinstall scripts in
|
||||
# dependencies (for example the sqlite3 native module would not be built). So
|
||||
# instead we run `yarn install`, which is going to install again all the
|
||||
# packages (but because it's already done it should be fast), and then run the
|
||||
# postinstall scripts, as well as build scripts.
|
||||
# For some reason there's both a .yarn/cache and .yarn/berry/cache that are
|
||||
# being generated, and both have the same content. Not clear why it does this
|
||||
# but we can delete it anyway. We can delete the cache because we use
|
||||
# `nodeLinker: node-modules`. If we ever implement Zero Install, we'll need to
|
||||
# keep the cache.
|
||||
#
|
||||
# Note that `yarn install` ignores `NODE_ENV=production` and will install dev
|
||||
# dependencies too, but this is fine because we need them to build the app.
|
||||
|
||||
RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds
|
||||
RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
|
||||
&& yarn cache clean \
|
||||
&& rm -rf .yarn/berry
|
||||
|
||||
ENV RUNNING_IN_DOCKER=1
|
||||
EXPOSE ${APP_PORT}
|
||||
|
||||
# Not clear what's the equivalent of "--prefix" in Yarn 3, so keep using npm for
|
||||
# now.
|
||||
CMD [ "npm", "--prefix", "packages/server", "start" ]
|
||||
# Call the command directly, without going via npm:
|
||||
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#cmd
|
||||
WORKDIR "/home/$user/packages/server"
|
||||
CMD [ "node", "dist/app.js" ]
|
||||
|
||||
# Build-time metadata
|
||||
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||
|
||||
@@ -300,7 +300,6 @@ To add a **Bucket Policy** from the AWS S3 Web Console, navigate to the **Permis
|
||||
{
|
||||
"Sid": "VisualEditor0",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:GetBucketLocation",
|
||||
|
||||
@@ -78,5 +78,8 @@
|
||||
"node-gyp": "^8.4.1",
|
||||
"nodemon": "^2.0.9"
|
||||
},
|
||||
"packageManager": "yarn@3.1.1"
|
||||
"packageManager": "yarn@3.1.1",
|
||||
"resolutions": {
|
||||
"markdown-it-multimd-table@4.1.1": "patch:markdown-it-multimd-table@npm:4.1.1#.yarn/patches/markdown-it-multimd-table-npm-4.1.1-47e334d4bd"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const path = require('path');
|
||||
const { dirname } = require('@joplin/lib/path-utils');
|
||||
const fs = require('fs-extra');
|
||||
const { ipcMain } = require('electron');
|
||||
const { openProcessManager } = require('@joplin/electron-process-manager');
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@@ -42,10 +41,6 @@ export default class ElectronAppWrapper {
|
||||
this.initialCallbackUrl_ = initialCallbackUrl;
|
||||
}
|
||||
|
||||
public openProcessManager() {
|
||||
openProcessManager(require('@electron/remote/main'));
|
||||
}
|
||||
|
||||
electronApp() {
|
||||
return this.electronApp_;
|
||||
}
|
||||
|
||||
@@ -446,8 +446,9 @@ class Application extends BaseApplication {
|
||||
|
||||
await this.checkForLegacyTemplates();
|
||||
|
||||
// Note: Auto-update currently doesn't work in Linux: it downloads the update
|
||||
// but then doesn't install it on exit.
|
||||
// Note: Auto-update is a misnomer in the code.
|
||||
// The code below only checks, if a new version is available.
|
||||
// We only allow Windows and macOS users to automatically check for updates
|
||||
if (shim.isWindows() || shim.isMac()) {
|
||||
const runAutoUpdateCheck = () => {
|
||||
if (Setting.value('autoUpdateEnabled')) {
|
||||
|
||||
@@ -30,10 +30,6 @@ export class Bridge {
|
||||
return !this.electronApp().electronApp().isPackaged;
|
||||
}
|
||||
|
||||
public openProcessManager() {
|
||||
this.electronApp().openProcessManager();
|
||||
}
|
||||
|
||||
// The build directory contains additional external files that are going to
|
||||
// be packaged by Electron Builder. This is for files that need to be
|
||||
// accessed outside of the Electron app (for example the application icon).
|
||||
|
||||
@@ -56,7 +56,7 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
|
||||
const containerStyle = Object.assign({}, theme.containerStyle, {
|
||||
overflowY: 'scroll',
|
||||
padding: theme.configScreenPadding,
|
||||
// padding: theme.configScreenPadding,
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '../Dialog';
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
export default function(props: Props) {
|
||||
const [folderTitle, setFolderTitle] = useState('');
|
||||
const [folderIcon, setFolderIcon] = useState<FolderIcon>();
|
||||
const titleInputRef = useRef(null);
|
||||
|
||||
const isNew = !props.folderId;
|
||||
|
||||
@@ -41,6 +42,14 @@ export default function(props: Props) {
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
titleInputRef.current.focus();
|
||||
|
||||
setTimeout(() => {
|
||||
titleInputRef.current.select();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
@@ -90,7 +99,7 @@ export default function(props: Props) {
|
||||
<div className="form">
|
||||
<div className="form-input-group">
|
||||
<label>{_('Title')}</label>
|
||||
<StyledInput type="text" value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
</div>
|
||||
|
||||
<div className="form-input-group">
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function styles(themeId: number) {
|
||||
return {
|
||||
container: {
|
||||
...theme.containerStyle,
|
||||
padding: theme.configScreenPadding,
|
||||
// padding: theme.configScreenPadding,
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
},
|
||||
actionsContainer: {
|
||||
|
||||
@@ -726,13 +726,6 @@ function useMenu(props: Props) {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'help:toggleTaskList',
|
||||
label: _('Open plugin task list'),
|
||||
click: () => {
|
||||
bridge().openProcessManager();
|
||||
},
|
||||
},
|
||||
|
||||
menuItemDic.toggleSafeMode,
|
||||
menuItemDic.openProfileDirectory,
|
||||
|
||||
@@ -67,7 +67,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
|
||||
usePluginServiceRegistration(ref);
|
||||
|
||||
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, editor_resize,
|
||||
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, editor_resize, getLineScrollPercent,
|
||||
} = useScrollHandler(editorRef, webviewRef, props.onScroll);
|
||||
|
||||
const codeMirror_change = useCallback((newBody: string) => {
|
||||
@@ -576,9 +576,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
const newBody = shared.toggleCheckbox(msg, props.content);
|
||||
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.updateBody(newBody);
|
||||
// To cancel CodeMirror's layout drift, the scroll position
|
||||
// is recorded before updated, and then it is restored.
|
||||
// Ref. https://github.com/laurent22/joplin/issues/5890
|
||||
const percent = getLineScrollPercent();
|
||||
editorRef.current.replaceRange(line, from, to);
|
||||
setEditorPercentScroll(percent);
|
||||
}
|
||||
} else if (msg === 'percentScroll') {
|
||||
const percent = arg0;
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
const ignoreNextEditorScrollTime_ = useRef(Date.now());
|
||||
const ignoreNextEditorScrollEventCount_ = useRef(0);
|
||||
const delayedSetEditorPercentScrollTimeoutID_ = useRef(null);
|
||||
const lastResizeHeight_ = useRef(NaN);
|
||||
|
||||
// Ignores one next scroll event for a short time.
|
||||
const ignoreNextEditorScrollEvent = () => {
|
||||
@@ -90,8 +91,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const editor_scroll = useCallback(() => {
|
||||
if (isNextEditorScrollEventIgnored()) return;
|
||||
|
||||
const ignored = isNextEditorScrollEventIgnored();
|
||||
const cm = editorRef.current;
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
const editorPercent = Math.max(0, Math.min(1, cm.getScrollPercent()));
|
||||
@@ -104,7 +104,9 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
// calculates GUI-independent line-based percent
|
||||
const percent = translateScrollPercentE2L(cm, editorPercent);
|
||||
scrollPercent_.current = percent;
|
||||
setViewerPercentScroll(percent);
|
||||
if (!ignored) {
|
||||
setViewerPercentScroll(percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [setViewerPercentScroll]);
|
||||
@@ -117,13 +119,29 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
}, []);
|
||||
|
||||
const editor_resize = useCallback((cm) => {
|
||||
if (cm) {
|
||||
restoreEditorPercentScroll();
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
// Only when resized, the scroll position is restored.
|
||||
const info = cm.getScrollInfo();
|
||||
const height = info.height - info.clientHeight;
|
||||
if (height !== lastResizeHeight_.current) {
|
||||
restoreEditorPercentScroll();
|
||||
lastResizeHeight_.current = height;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getLineScrollPercent = useCallback(() => {
|
||||
const cm = editorRef.current;
|
||||
if (isCodeMirrorReady(cm)) {
|
||||
const ePercent = cm.getScrollPercent();
|
||||
return translateScrollPercentE2L(cm, ePercent);
|
||||
} else {
|
||||
return scrollPercent_.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll, editor_resize,
|
||||
resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll, editor_resize, getLineScrollPercent,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const Root = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 30px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
const React = require('react');
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const CommandService = require('@joplin/lib/services/CommandService').default;
|
||||
|
||||
class TagItemComponent extends React.Component {
|
||||
render() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const style = Object.assign({}, theme.tagStyle);
|
||||
const title = this.props.title;
|
||||
const { title, id } = this.props;
|
||||
|
||||
return <span style={style}>{title}</span>;
|
||||
return <button style={style} onClick={() => CommandService.instance().execute('openTag', id)}>{title}</button>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function TagList(props: Props) {
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const props = {
|
||||
title: tags[i].title,
|
||||
id: tags[i].id,
|
||||
key: tags[i].id,
|
||||
};
|
||||
output.push(<TagItem {...props} />);
|
||||
|
||||
@@ -151,7 +151,7 @@ General classes
|
||||
|
||||
body, button {
|
||||
color: var(--joplin-color);
|
||||
font-size: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
div, span, a {
|
||||
@@ -159,7 +159,7 @@ div, span, a {
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
|
||||
&.-no-top-margin {
|
||||
margin-top: 0;
|
||||
@@ -193,7 +193,7 @@ div.form,
|
||||
|
||||
p {
|
||||
&.-small {
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,10 +134,9 @@
|
||||
"7zip-bin-win": "^2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.0.1",
|
||||
"@electron/remote": "^2.0.1",
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joeattardi/emoji-button": "^4.6.0",
|
||||
"@joplin/electron-process-manager": "workspace:^",
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"async-mutex": "^0.1.3",
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# First user has E2EE, but second one doesn't:
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Without E2EE:
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets" : ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 253 KiB |
1
packages/electron-process-manager/.gitignore
vendored
1
packages/electron-process-manager/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<content url="file://$MODULE_DIR$/../electron-process-reporter" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
81
packages/electron-process-manager/.idea/misc.xml
generated
81
packages/electron-process-manager/.idea/misc.xml
generated
@@ -1,81 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="JSX" />
|
||||
</component>
|
||||
<component name="MarkdownProjectSettings" wasCopied="true">
|
||||
<PreviewSettings splitEditorLayout="SPLIT" splitEditorPreview="PREVIEW" useGrayscaleRendering="false" zoomFactor="1.5" maxImageWidth="0" showGitHubPageIfSynced="false" allowBrowsingInPreview="false" synchronizePreviewPosition="true" highlightPreviewType="NONE" highlightFadeOut="5" highlightOnTyping="true" synchronizeSourcePosition="true" verticallyAlignSourceAndPreviewSyncPosition="true" showSearchHighlightsInPreview="false" showSelectionInPreview="true" openRemoteLinks="true" replaceUnicodeEmoji="false" lastLayoutSetsDefault="false">
|
||||
<PanelProvider>
|
||||
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.panel" providerName="Default - Swing" />
|
||||
</PanelProvider>
|
||||
</PreviewSettings>
|
||||
<ParserSettings gitHubSyntaxChange="false" emojiShortcuts="0" emojiImages="0">
|
||||
<PegdownExtensions>
|
||||
<option name="ABBREVIATIONS" value="false" />
|
||||
<option name="ANCHORLINKS" value="true" />
|
||||
<option name="ASIDE" value="false" />
|
||||
<option name="ATXHEADERSPACE" value="true" />
|
||||
<option name="AUTOLINKS" value="true" />
|
||||
<option name="DEFINITIONS" value="false" />
|
||||
<option name="DEFINITION_BREAK_DOUBLE_BLANK_LINE" value="false" />
|
||||
<option name="FENCED_CODE_BLOCKS" value="true" />
|
||||
<option name="FOOTNOTES" value="false" />
|
||||
<option name="HARDWRAPS" value="false" />
|
||||
<option name="HTML_DEEP_PARSER" value="false" />
|
||||
<option name="INSERTED" value="false" />
|
||||
<option name="QUOTES" value="false" />
|
||||
<option name="RELAXEDHRULES" value="true" />
|
||||
<option name="SMARTS" value="false" />
|
||||
<option name="STRIKETHROUGH" value="true" />
|
||||
<option name="SUBSCRIPT" value="false" />
|
||||
<option name="SUPERSCRIPT" value="false" />
|
||||
<option name="SUPPRESS_HTML_BLOCKS" value="false" />
|
||||
<option name="SUPPRESS_INLINE_HTML" value="false" />
|
||||
<option name="TABLES" value="true" />
|
||||
<option name="TASKLISTITEMS" value="true" />
|
||||
<option name="TOC" value="false" />
|
||||
<option name="WIKILINKS" value="true" />
|
||||
</PegdownExtensions>
|
||||
<ParserOptions>
|
||||
<option name="ADMONITION_EXT" value="false" />
|
||||
<option name="ATTRIBUTES_EXT" value="false" />
|
||||
<option name="COMMONMARK_LISTS" value="true" />
|
||||
<option name="DUMMY" value="false" />
|
||||
<option name="EMOJI_SHORTCUTS" value="true" />
|
||||
<option name="ENUMERATED_REFERENCES_EXT" value="false" />
|
||||
<option name="FLEXMARK_FRONT_MATTER" value="false" />
|
||||
<option name="GFM_LOOSE_BLANK_LINE_AFTER_ITEM_PARA" value="false" />
|
||||
<option name="GFM_TABLE_RENDERING" value="true" />
|
||||
<option name="GITBOOK_URL_ENCODING" value="false" />
|
||||
<option name="GITHUB_LISTS" value="false" />
|
||||
<option name="GITHUB_WIKI_LINKS" value="true" />
|
||||
<option name="HEADER_ID_NO_DUPED_DASHES" value="false" />
|
||||
<option name="JEKYLL_FRONT_MATTER" value="false" />
|
||||
<option name="NO_TEXT_ATTRIBUTES" value="false" />
|
||||
<option name="PARSE_HTML_ANCHOR_ID" value="false" />
|
||||
<option name="SIM_TOC_BLANK_LINE_SPACER" value="true" />
|
||||
</ParserOptions>
|
||||
</ParserSettings>
|
||||
<HtmlSettings headerTopEnabled="false" headerBottomEnabled="false" bodyTopEnabled="false" bodyBottomEnabled="false" embedUrlContent="false" addPageHeader="true" embedImages="false" embedHttpImages="false" imageUriSerials="false">
|
||||
<GeneratorProvider>
|
||||
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.generator" providerName="Default Swing HTML Generator" />
|
||||
</GeneratorProvider>
|
||||
<headerTop />
|
||||
<headerBottom />
|
||||
<bodyTop />
|
||||
<bodyBottom />
|
||||
</HtmlSettings>
|
||||
<CssSettings previewScheme="UI_SCHEME" cssUri="" isCssUriEnabled="false" isCssUriSerial="true" isCssTextEnabled="false" isDynamicPageWidth="true">
|
||||
<StylesheetProvider>
|
||||
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.css" providerName="Default Swing Stylesheet" />
|
||||
</StylesheetProvider>
|
||||
<ScriptProviders />
|
||||
<cssText />
|
||||
<cssUriHistory />
|
||||
</CssSettings>
|
||||
<HtmlExportSettings updateOnSave="false" parentDir="" targetDir="" cssDir="" scriptDir="" plainHtml="false" imageDir="" copyLinkedImages="false" imageUniquifyType="0" targetExt="" useTargetExt="false" noCssNoScripts="false" linkToExportedHtml="true" exportOnSettingsChange="true" regenerateOnProjectOpen="false" linkFormatType="HTTP_ABSOLUTE" />
|
||||
<LinkMapSettings>
|
||||
<textMaps />
|
||||
</LinkMapSettings>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/electron-process-manager.iml" filepath="$PROJECT_DIR$/.idea/electron-process-manager.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
packages/electron-process-manager/.idea/vcs.xml
generated
6
packages/electron-process-manager/.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
107
packages/electron-process-manager/.idea/workspace.xml
generated
107
packages/electron-process-manager/.idea/workspace.xml
generated
@@ -1,107 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b935834e-d3d3-420f-991d-e49e232dd71b" name="Default Changelist" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="FileColors">
|
||||
<fileColor scope="Non-Project Files (Material Default)" color="2E3C43" />
|
||||
<fileColor scope="Non-Project Files (Material Darker)" color="323232" />
|
||||
<fileColor scope="Non-Project Files (Material Lighter)" color="eae8e8" />
|
||||
<fileColor scope="Non-Project Files (Material Palenight)" color="2f2e43" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="PREVIOUS_COMMIT_AUTHORS">
|
||||
<list>
|
||||
<option value="Kris Dages <krisdages@git.whiteboxsoftware.net>" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="GitSEFilterConfiguration">
|
||||
<file-type-list>
|
||||
<filtered-out-file-type name="LOCAL_BRANCH" />
|
||||
<filtered-out-file-type name="REMOTE_BRANCH" />
|
||||
<filtered-out-file-type name="TAG" />
|
||||
<filtered-out-file-type name="COMMIT_BY_MESSAGE" />
|
||||
</file-type-list>
|
||||
</component>
|
||||
<component name="ProjectId" id="1hYhEkXEjWlOUW5DO64AadCwogW" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="autoscrollFromSource" value="true" />
|
||||
<option name="autoscrollToSource" value="true" />
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">
|
||||
<property name="RunOnceActivity.OpenProjectViewOnStart" value="true" />
|
||||
<property name="WebServerToolWindowFactoryState" value="false" />
|
||||
<property name="last_opened_file_path" value="$PROJECT_DIR$/../electron-process-reporter" />
|
||||
<property name="node.js.detected.package.eslint" value="true" />
|
||||
<property name="node.js.detected.package.tslint" value="true" />
|
||||
<property name="node.js.selected.package.eslint" value="(autodetect)" />
|
||||
<property name="node.js.selected.package.tslint" value="(autodetect)" />
|
||||
<property name="nodejs_interpreter_path.stuck_in_default_project" value="undefined stuck path" />
|
||||
<property name="nodejs_npm_path_reset_for_default_project" value="true" />
|
||||
<property name="nodejs_package_manager_path" value="yarn" />
|
||||
<property name="settings.editor.selected.configurable" value="web-ide.project.structure" />
|
||||
<property name="ts.external.directory.path" value="$APPLICATION_HOME_DIR$/plugins/JavaScriptLanguage/jsLanguageServicesImpl/external" />
|
||||
<property name="vue.rearranger.settings.migration" value="true" />
|
||||
</component>
|
||||
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="b935834e-d3d3-420f-991d-e49e232dd71b" name="Default Changelist" comment="" />
|
||||
<created>1600193824206</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1600193824206</updated>
|
||||
<workItem from="1600193825399" duration="438000" />
|
||||
<workItem from="1606177958157" duration="419000" />
|
||||
<workItem from="1616558380418" duration="1481000" />
|
||||
<workItem from="1627318845586" duration="3411000" />
|
||||
<workItem from="1627323366882" duration="72000" />
|
||||
<workItem from="1627489266598" duration="1192000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="(fix) Add `enableRemoteModule: true` to ProcessManagerWindow webPreferences Fixes broken UI in Electron 10">
|
||||
<created>1600193995342</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1600193995342</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="(breaking) Use `@electron/remote` instead of deprecated `remote` module Updated min electron version to 10. Bump version to 2.0.0">
|
||||
<created>1627322100905</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1627322100905</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="Update README.md for fork">
|
||||
<created>1627490446165</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1627490446165</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="4" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="(fix) Add `enableRemoteModule: true` to ProcessManagerWindow webPreferences Fixes broken UI in Electron 10" />
|
||||
<MESSAGE value="(breaking) Use `@electron/remote` instead of deprecated `remote` module Updated min electron version to 10. Bump version to 2.0.0" />
|
||||
<MESSAGE value="Update README.md for fork" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Update README.md for fork" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,92 +0,0 @@
|
||||
# Process Manager UI for Electron Apps
|
||||
|
||||
* * *
|
||||
|
||||
2022-01-06: Forked from https://github.com/krisdages/electron-process-manager
|
||||
|
||||
* * *
|
||||
|
||||
## Fork using @electron/remote instead of builtin remote module
|
||||
* Minimum electron version is `10`
|
||||
* [@electron/remote](https://github.com/electron/remote) is a peerDependency. It needs to be initialized in the main process. Follow the instructions in the link.
|
||||
|
||||
## Original 1.0 Readme
|
||||
|
||||
This package provides a process manager UI for Electron applications.
|
||||
|
||||
It opens a window displaying a table of every processes run by the Electron application with information (type, URL for `webContents`, memory..).
|
||||
|
||||
[](https://badge.fury.io/js/electron-process-manager)
|
||||
|
||||

|
||||
|
||||
~~:warning: For `@electron>=3.0.0, <7.x`, use version `0.7.1` of this package.
|
||||
For versions `>=7.x`, use latest.~~
|
||||
|
||||
It can be useful to debug performance of an app with several `webview`.
|
||||
|
||||
It's inspired from Chrome's task manager.
|
||||
|
||||
## Features
|
||||
|
||||
- [ ] Memory reporting
|
||||
- [ ] Link memory data to web-contents (for electron >=1.7.1)
|
||||
- [x] Kill a process from the UI
|
||||
- [x] Open developer tools for a given process
|
||||
- [x] CPU metrics
|
||||
- [x] Sort by columns
|
||||
|
||||
⚠️ Unfortunately, memory info are no longer available in Electron>=4 (see [electron/electron#16179](https://github.com/electron/electron/issues/16179))
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ npm install electron-process-manager
|
||||
```
|
||||
|
||||
## Usage
|
||||
```js
|
||||
const { openProcessManager } = require('electron-process-manager');
|
||||
|
||||
openProcessManager();
|
||||
```
|
||||
|
||||
## Options
|
||||
`openProcessManager` function can take options in paramters
|
||||
|
||||
#### options.defaultSorting
|
||||
**defaultSorting.how**: `'ascending' | 'descending'`
|
||||
|
||||
**defaultSorting.path**:
|
||||
|
||||
| Field name | path |
|
||||
|--------------------|----------------------------|
|
||||
| Pid | 'pid' |
|
||||
| WebContents Domain | 'webContents.0.URLDomain' |
|
||||
| Process Type | 'webContents.0.type' |
|
||||
| Private Memory | 'memory.privateBytes' |
|
||||
| Shared Memory | 'memory.sharedBytes' |
|
||||
| Working Set Size | 'memory.workingSetSize' |
|
||||
| % CPU | 'cpu.percentCPUUsage' |
|
||||
| Idle Wake Ups /s | 'cpu.idleWakeupsPerSecond' |
|
||||
| WebContents Id | 'webContents.0.id' |
|
||||
| WebContents Type | 'webContents.0.type' |
|
||||
| WebContents URL | 'webContents.0.URL' |
|
||||
|
||||
example:
|
||||
```js
|
||||
const { openProcessManager } = require('electron-process-manager');
|
||||
|
||||
openProcessManager({ how: 'descending', path: 'cpu.percentCPUUsage' });
|
||||
```
|
||||
|
||||
## Future
|
||||
|
||||
- Add physical memory (noted as "Memory" in Chrome's task manager)
|
||||
- Add networks metrics
|
||||
|
||||
Pull requests welcome :)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@joplin/electron-process-manager",
|
||||
"version": "2.0.1",
|
||||
"description": "Process manager UI for Electron applications - Fork with support for @electron/remote",
|
||||
"main": "src/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.0.0",
|
||||
"@babel/preset-env": "^7.0.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"babel-loader": "^8.0.0",
|
||||
"bluebird": "^3.7.1",
|
||||
"bluebird-extra": "^2.0.0",
|
||||
"electron": "^10.4.7",
|
||||
"electron-default-menu": "1.0.1",
|
||||
"filesize": "^5.0.3",
|
||||
"format-number": "^3.0.0",
|
||||
"object-path": "^0.11.4",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^16.11.0",
|
||||
"react-dom": "^16.11.0",
|
||||
"spectron": "^12.0.0",
|
||||
"webpack": "^2.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": ">= 10"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-process-reporter": "^1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Process Manager</title>
|
||||
<link rel="stylesheet" href="vendor/photon.css">
|
||||
<style>
|
||||
.process-table {
|
||||
/*margin: 10px;*/
|
||||
}
|
||||
.process-table-container {
|
||||
overflow-x: scroll;
|
||||
flex: 2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="window"></div>
|
||||
<script src="dist/ui-bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,64 +0,0 @@
|
||||
const { EventEmitter } = require('events');
|
||||
const process = require('process');
|
||||
const { webContents } = require('electron');
|
||||
|
||||
const ProcessManagerWindow = require('./ProcessManagerWindow.js');
|
||||
|
||||
const defaultOptions = { defaultSorting: { path: null, how: null } };
|
||||
|
||||
class ProcessManager extends EventEmitter {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// legacy
|
||||
this.openProcessManager = this.open.bind(this);
|
||||
}
|
||||
|
||||
// in case this isn't already done in the app.
|
||||
//
|
||||
// No longer needed because caller should setup electron/remote
|
||||
//
|
||||
// initializeElectronRemote() {
|
||||
// return require('@electron/remote/main').initialize();
|
||||
// }
|
||||
|
||||
// We pass the electron/remote/main instance to the manager to ensure it's
|
||||
// using the same as the main application.
|
||||
//
|
||||
// When using a peer dependency it seems the package ends up using its own
|
||||
// instance, which doesn't work.
|
||||
open(electronRemote, options = defaultOptions) {
|
||||
if (this.window) {
|
||||
this.window.focus();
|
||||
}
|
||||
|
||||
this.window = new ProcessManagerWindow(electronRemote);
|
||||
this.window.defaultSorting = options.defaultSorting || {};
|
||||
this.window.showWhenReady();
|
||||
this.window.on('kill-process', pid => this.killProcess(pid));
|
||||
this.window.on('open-dev-tools', webContentsId => this.openDevTools(webContentsId));
|
||||
this.window.on('closed', () => this.window = null);
|
||||
this.emit('open-window', this.window);
|
||||
|
||||
return this.window;
|
||||
}
|
||||
|
||||
killProcess(pid) {
|
||||
this.emit('will-kill-process', pid, this.window);
|
||||
process.kill(pid);
|
||||
this.emit('killed-process', pid, this.window);
|
||||
}
|
||||
|
||||
openDevTools(webContentsId) {
|
||||
this.emit('will-open-dev-tools', webContentsId, this.window);
|
||||
|
||||
const wc = webContents.fromId(webContentsId);
|
||||
wc.openDevTools({ mode: 'detach' });
|
||||
|
||||
this.emit('did-open-dev-tools', webContentsId, this.window);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = ProcessManager;
|
||||
@@ -1,78 +0,0 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
const { onExtendedProcessMetrics } = require('electron-process-reporter');
|
||||
|
||||
class ProcessManagerWindow extends BrowserWindow {
|
||||
|
||||
constructor(electronRemote, options) {
|
||||
const winOptions = Object.assign({
|
||||
width: 800,
|
||||
height: 300,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nodeIntegrationInWorker: true,
|
||||
webviewTag: true,
|
||||
enableRemoteModule: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
}, options || {});
|
||||
|
||||
super(winOptions);
|
||||
this.options = options;
|
||||
|
||||
this.attachProcessReporter();
|
||||
|
||||
const indexHtml = `file://${path.join(__dirname, '..', 'process-manager.html')}`;
|
||||
this.loadURL(indexHtml);
|
||||
|
||||
console.info('IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII', electronRemote);
|
||||
electronRemote.enable(this.webContents);
|
||||
|
||||
setTimeout(() => {
|
||||
this.openDevTools();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
showWhenReady() {
|
||||
this.once('ready-to-show', () => {
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
sendStatsReport(reportData) {
|
||||
if (!this.webContents) return;
|
||||
this.webContents.send('process-manager:data', reportData);
|
||||
}
|
||||
|
||||
openDevTools() {
|
||||
this.webContents.openDevTools();
|
||||
}
|
||||
|
||||
attachProcessReporter() {
|
||||
this.subscription = onExtendedProcessMetrics(app)
|
||||
.subscribe(report => this.sendStatsReport(report));
|
||||
ipcMain.on('process-manager:kill-process', (e, pid) => {
|
||||
// ignore if not for us
|
||||
if (!this || this.isDestroyed()) return;
|
||||
if (e.sender !== this.webContents) return;
|
||||
|
||||
this.emit('kill-process', pid);
|
||||
});
|
||||
ipcMain.on('process-manager:open-dev-tools', (e, webContentsId) => {
|
||||
// ignore if not for us
|
||||
if (!this || this.isDestroyed()) return;
|
||||
if (e.sender !== this.webContents) return;
|
||||
|
||||
|
||||
this.emit('open-dev-tools', webContentsId);
|
||||
|
||||
});
|
||||
this.on('closed', () => {
|
||||
if (this.subscription) this.subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProcessManagerWindow;
|
||||
@@ -1,4 +0,0 @@
|
||||
const ProcessManager = require('./ProcessManager.js');
|
||||
|
||||
// singleton
|
||||
module.exports = new ProcessManager();
|
||||
@@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import objectPath from 'object-path';
|
||||
import ProcessTable from './ProcessTable';
|
||||
import ToolBar from './ToolBar';
|
||||
|
||||
export default class ProcessManager extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
processData: null,
|
||||
selectedPid: null,
|
||||
sorting: {
|
||||
path: null,
|
||||
how: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
// TODO: disabled for now - the remote package would need to be passed to this script somehow.
|
||||
//
|
||||
// this.setState({ sorting: remote.getCurrentWindow().defaultSorting });
|
||||
ipcRenderer.on('process-manager:data', (_, data) => {
|
||||
this.setState({ processData: data });
|
||||
})
|
||||
}
|
||||
|
||||
canKill() {
|
||||
if (!this.state.selectedPid) return false;
|
||||
const pids = this.state.processData.map(p => p.pid);
|
||||
|
||||
// verify that select pid is in list of processes
|
||||
return pids.indexOf(this.state.selectedPid) !== -1;
|
||||
}
|
||||
|
||||
canOpenDevTool() {
|
||||
return this.canKill() && this.getWebContentsIdForSelectedProcess() !== null;
|
||||
}
|
||||
|
||||
getWebContentsIdForSelectedProcess() {
|
||||
const { processData, selectedPid } = this.state;
|
||||
if (!selectedPid) return null;
|
||||
|
||||
const process = processData.find(p => p.pid === selectedPid);
|
||||
if (!process || !process.webContents || process.webContents.length === 0) return null;
|
||||
|
||||
return process.webContents[0].id;
|
||||
}
|
||||
|
||||
handleKillProcess() {
|
||||
const pid = this.state.selectedPid;
|
||||
if (!pid) return;
|
||||
ipcRenderer.send('process-manager:kill-process', pid);
|
||||
}
|
||||
|
||||
handleOpenDevTool() {
|
||||
const webContentsId = this.getWebContentsIdForSelectedProcess();
|
||||
ipcRenderer.send('process-manager:open-dev-tools', webContentsId);
|
||||
}
|
||||
|
||||
getProcessData() {
|
||||
const { processData, sorting } = this.state;
|
||||
|
||||
if (!sorting.path || !sorting.how) return processData;
|
||||
|
||||
return processData.sort((p1, p2) => {
|
||||
const p1Metric = objectPath.get(p1, sorting.path);
|
||||
const p2Metric = objectPath.get(p2, sorting.path);
|
||||
|
||||
if (p1Metric === p2Metric) return 0;
|
||||
const comp = p1Metric < p2Metric ? -1 : 1;
|
||||
|
||||
return sorting.how == 'ascending' ? comp : -comp;
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const { processData } = this.state;
|
||||
if (!processData) return (<span>No data</span>);
|
||||
|
||||
return (
|
||||
<div className="window">
|
||||
<header className="toolbar toolbar-header">
|
||||
<ToolBar
|
||||
disableKill={!this.canKill()}
|
||||
onKillClick={this.handleKillProcess.bind(this)}
|
||||
disabelOpenDevTool={!this.canOpenDevTool()}
|
||||
onOpenDevToolClick={this.handleOpenDevTool.bind(this)}
|
||||
|
||||
/>
|
||||
</header>
|
||||
<div className="process-table-container">
|
||||
<ProcessTable
|
||||
processData={this.getProcessData()}
|
||||
selectedPid={this.state.selectedPid}
|
||||
sorting={this.state.sorting}
|
||||
onSortingChange={sorting => this.setState({ sorting })}
|
||||
onSelectedPidChange={pid => this.setState({ selectedPid: pid })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import filesize from 'filesize';
|
||||
import format from 'format-number';
|
||||
|
||||
const KB = 1024;
|
||||
const formatPercentage = format({
|
||||
round: 1,
|
||||
padRight: 1
|
||||
});
|
||||
|
||||
export default class ProcessRow extends React.Component {
|
||||
static propTypes = {
|
||||
pid: PropTypes.number,
|
||||
type: PropTypes.string,
|
||||
memory: PropTypes.shape({
|
||||
peakWorkingSetSize: PropTypes.number,
|
||||
workingSetSize: PropTypes.number
|
||||
}),
|
||||
cpu: PropTypes.shape({
|
||||
percentCPUUsage: PropTypes.number,
|
||||
idleWakeupsPerSecond: PropTypes.number
|
||||
}),
|
||||
webContents: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
type: PropTypes.string,
|
||||
URL: PropTypes.string,
|
||||
URLDomain: PropTypes.string
|
||||
})),
|
||||
selected: PropTypes.bool,
|
||||
onSelect: PropTypes.func
|
||||
}
|
||||
|
||||
render() {
|
||||
const { webContents, memory } = this.props;
|
||||
if (!webContents || webContents.length === 0) {
|
||||
return (
|
||||
<tr
|
||||
className={this.props.selected ? 'selected': ''}
|
||||
onClick={this.props.onSelect}
|
||||
>
|
||||
<td>{this.props.pid}</td>
|
||||
<td></td>
|
||||
<td>{this.props.type}</td>
|
||||
<td>{memory ? filesize(memory.workingSetSize*KB) : 'N/A'}</td>
|
||||
<td>{formatPercentage(this.props.cpu.percentCPUUsage)}</td>
|
||||
<td>{this.props.cpu.idleWakeupsPerSecond}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)
|
||||
} else {
|
||||
// FIX ME: we consider we have only have 1 webContents per process
|
||||
const wc = webContents[0];
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={this.props.selected ? 'selected': ''}
|
||||
onClick={this.props.onSelect}
|
||||
>
|
||||
<td>{this.props.pid}</td>
|
||||
<td>{wc.URLDomain}</td>
|
||||
<td>{this.props.type}</td>
|
||||
<td>{memory ? filesize(memory.workingSetSize*KB) : 'N/A'}</td>
|
||||
<td>{formatPercentage(this.props.cpu.percentCPUUsage)}</td>
|
||||
<td>{this.props.cpu.idleWakeupsPerSecond}</td>
|
||||
<td>{wc.id}</td>
|
||||
<td>{wc.type}</td>
|
||||
<td>{wc.URL}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ProcessRow from './ProcessRow';
|
||||
import ProcessTableHeader from './ProcessTableHeader';
|
||||
|
||||
export default class ProcessTable extends React.Component {
|
||||
static propTypes = {
|
||||
processData: PropTypes.arrayOf(PropTypes.object),
|
||||
selectedPid: PropTypes.number,
|
||||
sorting: PropTypes.PropTypes.shape({
|
||||
path: PropTypes.string,
|
||||
how: PropTypes.string
|
||||
}),
|
||||
onSortingChange: PropTypes.func,
|
||||
onSelectedPidChange: PropTypes.func
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<table className="process-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<ProcessTableHeader
|
||||
path='pid'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>Pid</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='webContents.0.URLDomain'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>WebContents Domain</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='webContents.0.type'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>Process Type</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='memory.workingSetSize'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>Working Set Size</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='cpu.percentCPUUsage'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>% CPU</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='cpu.idleWakeupsPerSecond'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>Idle Wake Ups /s</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='webContents.0.id'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>WebContents Id</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='webContents.0.type'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>WebContents Type</ProcessTableHeader>
|
||||
|
||||
<ProcessTableHeader
|
||||
path='webContents.0.URL'
|
||||
sorting={this.props.sorting}
|
||||
onSortingChange={this.props.onSortingChange}
|
||||
>WebContents URL</ProcessTableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
this.props.processData.map(p =>
|
||||
<ProcessRow
|
||||
key={p.pid}
|
||||
{...p}
|
||||
onSelect={() => this.props.onSelectedPidChange(p.pid)}
|
||||
selected={this.props.selectedPid === p.pid}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
export default class ProcessTableHeader extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
path: PropTypes.string.isRequired,
|
||||
sorting: PropTypes.PropTypes.shape({
|
||||
path: PropTypes.string,
|
||||
how: PropTypes.string
|
||||
}),
|
||||
onSortingChange: PropTypes.func
|
||||
}
|
||||
|
||||
getSortCharacter() {
|
||||
if (!this.sortHow) return (
|
||||
<span> </span>
|
||||
);
|
||||
return this.sortHow == 'ascending' ? '👆' : '👇'
|
||||
}
|
||||
|
||||
get sortHow() {
|
||||
if (!this.props.sorting) return null;
|
||||
|
||||
if (this.props.sorting.path == this.props.path){
|
||||
return this.props.sorting.how;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
let nextSortHow = null;
|
||||
if(this.sortHow === null) {
|
||||
nextSortHow = 'ascending';
|
||||
} else if (this.sortHow === 'ascending') {
|
||||
nextSortHow = 'descending';
|
||||
} else {
|
||||
nextSortHow = null;
|
||||
}
|
||||
this.props.onSortingChange({
|
||||
path: this.props.path,
|
||||
how: nextSortHow
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<th onClick={this.handleClick}>
|
||||
{this.props.children}
|
||||
|
||||
{this.getSortCharacter()}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class ToolBar extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
onKillClick: PropTypes.func,
|
||||
disableKill: PropTypes.bool,
|
||||
onOpenDevToolClick: PropTypes.func,
|
||||
disabelOpenDevTool: PropTypes.bool
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="toolbar-actions">
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={this.props.disableKill}
|
||||
onClick={this.props.onKillClick}
|
||||
>
|
||||
End process
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={this.props.disabelOpenDevTool}
|
||||
onClick={this.props.onOpenDevToolClick}
|
||||
>
|
||||
Open Dev Tool
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
|
||||
import ProcessManager from './ProcessManager';
|
||||
|
||||
render(<ProcessManager/>, document.getElementById('app'));
|
||||
@@ -1,30 +0,0 @@
|
||||
const Application = require('spectron').Application;
|
||||
const { join } = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const app = new Application({
|
||||
env: { TEST_PROCESS_MANAGER: 1 },
|
||||
path: require(join(__dirname, '../node_modules/electron')),
|
||||
args: [join(__dirname, '../example/main.js')],
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await app.start();
|
||||
await app.client.waitUntilWindowLoaded();
|
||||
await app.electron.ipcRenderer.send('open-process-manager');
|
||||
// This looks to be incorrect signature for assert.
|
||||
// assert(app.client.getWindowCount(), 2);
|
||||
// There are 2 webviews on the index page. They are included in windowCount, so it's 4, not 2.
|
||||
assert.equal(await app.client.getWindowCount(), 4);
|
||||
await app.client.switchWindow(/process-manager\.html/);
|
||||
await (await app.client.$('#app .process-table')).waitForDisplayed({ timeout: 60000 });
|
||||
await app.stop();
|
||||
} catch (error) {
|
||||
console.error('Test failed', error);
|
||||
if (app && app.isRunning()) {
|
||||
await app.stop();
|
||||
process.exit(1);
|
||||
} else { process.exit(1); }
|
||||
}
|
||||
})();
|
||||
2341
packages/electron-process-manager/vendor/photon.css
vendored
2341
packages/electron-process-manager/vendor/photon.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
|
||||
const BUILD_DIR = path.resolve(__dirname, 'dist');
|
||||
|
||||
const config = {
|
||||
entry: path.resolve(__dirname, 'src/ui/index.js'),
|
||||
// mode: 'development',
|
||||
devtool: 'eval-source-map',
|
||||
output: {
|
||||
path: BUILD_DIR,
|
||||
filename: 'ui-bundle.js',
|
||||
},
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.(jsx|js)?$/,
|
||||
loader: 'babel-loader',
|
||||
include: path.resolve(__dirname, 'src/ui'),
|
||||
},
|
||||
],
|
||||
},
|
||||
target: 'electron-renderer',
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -10,6 +10,9 @@
|
||||
"keywords": [
|
||||
"joplin-plugin"
|
||||
],
|
||||
"files": [
|
||||
"publish"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.0.14",
|
||||
"chalk": "^4.1.0",
|
||||
|
||||
@@ -98,14 +98,14 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
|
||||
// We could implement that here, but the above workaround saves some code.
|
||||
|
||||
static async checkConfig(options) {
|
||||
const fileApi = await SyncTargetAmazonS3.newFileApi_(SyncTargetAmazonS3.id(), options);
|
||||
fileApi.requestRepeatCount_ = 0;
|
||||
|
||||
const output = {
|
||||
ok: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
try {
|
||||
const fileApi = await SyncTargetAmazonS3.newFileApi_(SyncTargetAmazonS3.id(), options);
|
||||
fileApi.requestRepeatCount_ = 0;
|
||||
|
||||
const headBucketReq = new Promise((resolve, reject) => {
|
||||
fileApi.driver().api().send(
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const runtime = (): CommandRuntime => {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
reg.logger().info('Not authentified with sync target - please check your credential.');
|
||||
reg.logger().error('Not authenticated with sync target - please check your credentials.');
|
||||
return 'error';
|
||||
}
|
||||
|
||||
@@ -43,8 +43,13 @@ export const runtime = (): CommandRuntime => {
|
||||
try {
|
||||
sync = await reg.syncTarget().synchronizer();
|
||||
} catch (error) {
|
||||
reg.logger().info('Could not acquire synchroniser:');
|
||||
reg.logger().info(error);
|
||||
reg.logger().error('Could not initialise synchroniser: ');
|
||||
reg.logger().error(error);
|
||||
error.message = `Could not initialise synchroniser: ${error.message}`;
|
||||
utils.store.dispatch({
|
||||
type: 'SYNC_REPORT_UPDATE',
|
||||
report: { errors: [error] },
|
||||
});
|
||||
return 'error';
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ shared.toggleIsTodo_onPress = function(comp) {
|
||||
comp.setState(newState);
|
||||
};
|
||||
|
||||
shared.toggleCheckbox = function(ipcMessage, noteBody) {
|
||||
function toggleCheckboxLine(ipcMessage, noteBody) {
|
||||
const newBody = noteBody.split('\n');
|
||||
const p = ipcMessage.split(':');
|
||||
const lineIndex = Number(p[p.length - 1]);
|
||||
@@ -281,7 +281,18 @@ shared.toggleCheckbox = function(ipcMessage, noteBody) {
|
||||
} else {
|
||||
line = line.replace(/- \[x\] /i, '- [ ] ');
|
||||
}
|
||||
return [newBody, lineIndex, line];
|
||||
}
|
||||
|
||||
shared.toggleCheckboxRange = function(ipcMessage, noteBody) {
|
||||
const [lineIndex, line] = toggleCheckboxLine(ipcMessage, noteBody).slice(1);
|
||||
const from = { line: lineIndex, ch: 0 };
|
||||
const to = { line: lineIndex, ch: line.length };
|
||||
return { line, from, to };
|
||||
};
|
||||
|
||||
shared.toggleCheckbox = function(ipcMessage, noteBody) {
|
||||
const [newBody, lineIndex, line] = toggleCheckboxLine(ipcMessage, noteBody);
|
||||
newBody[lineIndex] = line;
|
||||
return newBody.join('\n');
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ shared.synchronize_press = async function(comp) {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
reg.logger().info('Not authentified with sync target - please check your credential.');
|
||||
reg.logger().error('Not authenticated with sync target - please check your credentials.');
|
||||
return 'error';
|
||||
}
|
||||
|
||||
@@ -113,8 +113,13 @@ shared.synchronize_press = async function(comp) {
|
||||
try {
|
||||
sync = await reg.syncTarget().synchronizer();
|
||||
} catch (error) {
|
||||
reg.logger().info('Could not acquire synchroniser:');
|
||||
reg.logger().info(error);
|
||||
reg.logger().error('Could not initialise synchroniser: ');
|
||||
reg.logger().error(error);
|
||||
error.message = `Could not initialise synchroniser: ${error.message}`;
|
||||
comp.props.dispatch({
|
||||
type: 'SYNC_REPORT_UPDATE',
|
||||
report: { errors: [error] },
|
||||
});
|
||||
return 'error';
|
||||
}
|
||||
|
||||
|
||||
@@ -1190,7 +1190,7 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically update the application') },
|
||||
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
|
||||
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
|
||||
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
|
||||
'sync.interval': {
|
||||
|
||||
@@ -89,6 +89,7 @@ export default class OneDriveApi {
|
||||
scope: 'files.readwrite offline_access sites.readwrite.all',
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
prompt: 'login',
|
||||
};
|
||||
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${stringify(query)}`;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Command } from './types';
|
||||
*
|
||||
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands)
|
||||
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands)
|
||||
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.ts)
|
||||
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts)
|
||||
*
|
||||
* To view what arguments are supported, you can open any of these files
|
||||
* and look at the `execute()` command.
|
||||
|
||||
@@ -227,6 +227,7 @@ function addExtraStyles(style: any) {
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
borderRadius: 100,
|
||||
borderWidth: 0,
|
||||
};
|
||||
|
||||
style.toolbarStyle = {
|
||||
|
||||
@@ -51,6 +51,8 @@ async function extractPluginFilesFromPackage(existingManifests: any, workDir: st
|
||||
|
||||
const pluginDir = resolveRelativePathWithinDir(workDir, 'node_modules', packageName, 'publish');
|
||||
|
||||
if (!(await fs.pathExists(pluginDir))) throw new Error(`Could not find publish directory at ${pluginDir}`);
|
||||
|
||||
const files = await fs.readdir(pluginDir);
|
||||
const manifestFilePath = path.resolve(pluginDir, files.find((f: any) => path.extname(f) === '.json'));
|
||||
const pluginFilePath = path.resolve(pluginDir, files.find((f: any) => path.extname(f) === '.jpl'));
|
||||
|
||||
@@ -16,4 +16,6 @@ module.exports = {
|
||||
'jest-expect-message',
|
||||
`${__dirname}/jest.setup.js`,
|
||||
],
|
||||
|
||||
bail: true,
|
||||
};
|
||||
|
||||
@@ -61,7 +61,8 @@ ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ul.pagination-list li {
|
||||
ul.pagination-list li,
|
||||
ul.menu-list li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
||||
116
packages/server/readme/CLA.md
Normal file
116
packages/server/readme/CLA.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Individual Contributor License Agreement
|
||||
|
||||
Thank you for your interest in Joplin Server, owned by Cozic Ltd (the
|
||||
"Company"). In order to clarify the intellectual property license granted with
|
||||
Contributions from any person or entity, the Company must have a Contributor
|
||||
License Agreement ("CLA") on file that has been signed by each Contributor,
|
||||
indicating agreement to the license terms below. This license is for your
|
||||
protection as a Contributor as well as the protection of the Company and its
|
||||
users; it does not change your rights to use your own Contributions for any
|
||||
other purpose.
|
||||
|
||||
Please complete and sign this Agreement, and then email a copy to
|
||||
cla@joplinapp.org only (do not copy any other persons or lists). Read this
|
||||
document carefully before signing and keep a copy for your records.
|
||||
|
||||
- Full name: **FULL NAME**
|
||||
|
||||
- Postal Address: **POSTAL ADDRESS**
|
||||
|
||||
- Country: **COUNTRY**
|
||||
|
||||
- E-Mail: **EMAIL**
|
||||
|
||||
- GitHub username: **GITHUB USERNAME**
|
||||
|
||||
You accept and agree to the following terms and conditions for Your present and
|
||||
future Contributions submitted to the Company. In return, the Company shall not
|
||||
use Your Contributions in a way that is contrary to the public benefit or
|
||||
inconsistent with its bylaws in effect at the time of the Contribution. Except
|
||||
for the license granted herein to the Company and recipients of software
|
||||
distributed by the Company, You reserve all right, title, and interest in and to
|
||||
Your Contributions.
|
||||
|
||||
1. Definitions.
|
||||
|
||||
* "You" (or "Your")
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized
|
||||
by the copyright owner that is making this Agreement with the Company. For
|
||||
legal entities, the entity making a Contribution and all other entities
|
||||
that control, are controlled by, or are under common control with that
|
||||
entity are considered to be a single Contributor. For the purposes of this
|
||||
definition, "control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or otherwise,
|
||||
or (ii) ownership of fifty percent (50%) or more of the outstanding shares,
|
||||
or (iii) beneficial ownership of such entity.
|
||||
|
||||
* "Contribution"
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any
|
||||
modifications or additions to an existing work, that is intentionally
|
||||
submitted by You to the Company for inclusion in, or documentation of, any
|
||||
of the products owned or managed by the Company (the "Work"). For the
|
||||
purposes of this definition, "submitted" means any form of electronic,
|
||||
verbal, or written communication sent to the Company or its
|
||||
representatives, including but not limited to communication on electronic
|
||||
mailing lists, source code control systems, and issue tracking systems that
|
||||
are managed by, or on behalf of, the Company for the purpose of discussing
|
||||
and improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||
Agreement, You hereby grant to the Company and to recipients of software
|
||||
distributed by the Company a perpetual, worldwide, non-exclusive, no-charge,
|
||||
royalty-free, irrevocable copyright license to reproduce, prepare derivative
|
||||
works of, publicly display, publicly perform, sublicense, and distribute Your
|
||||
Contributions and such derivative works.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this
|
||||
Agreement, You hereby grant to the Company and to recipients of software
|
||||
distributed by the Company a perpetual, worldwide, non-exclusive, no-charge,
|
||||
royalty-free, irrevocable (except as stated in this section) patent license
|
||||
to make, have made, use, offer to sell, sell, import, and otherwise transfer
|
||||
the Work, where such license applies only to those patent claims licensable
|
||||
by You that are necessarily infringed by Your Contribution(s) alone or by
|
||||
combination of Your Contribution(s) with the Work to which such
|
||||
Contribution(s) was submitted. If any entity institutes patent litigation
|
||||
against You or any other entity (including a cross-claim or counterclaim in a
|
||||
lawsuit) alleging that your Contribution, or the Work to which you have
|
||||
contributed, constitutes direct or contributory patent infringement, then any
|
||||
patent licenses granted to that entity under this Agreement for that
|
||||
Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. You represent that you are legally entitled to grant the above license. If
|
||||
your employer(s) has rights to intellectual property that you create that
|
||||
includes your Contributions, you represent that you have received permission
|
||||
to make Contributions on behalf of that employer, that your employer has
|
||||
waived such rights for your Contributions to the Company, or that your
|
||||
employer has executed a separate Corporate CLA with the Company.
|
||||
|
||||
5. You represent that each of Your Contributions is Your original creation (see
|
||||
section 7 for submissions on behalf of others). You represent that Your
|
||||
Contribution submissions include complete details of any third-party license
|
||||
or other restriction (including, but not limited to, related patents and
|
||||
trademarks) of which you are personally aware and which are associated with
|
||||
any part of Your Contributions.
|
||||
|
||||
6. You are not expected to provide support for Your Contributions, except to the
|
||||
extent You desire to provide support. You may provide support for free, for a
|
||||
fee, or not at all. Unless required by applicable law or agreed to in
|
||||
writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
|
||||
without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT,
|
||||
MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
7. Should You wish to submit work that is not Your original creation, You may
|
||||
submit it to the Company separately from any Contribution, identifying the
|
||||
complete details of its source and of any license or other restriction
|
||||
(including, but not limited to, related patents, trademarks, and license
|
||||
agreements) of which you are personally aware, and conspicuously marking the
|
||||
work as "Submitted on behalf of a third-party: **NAME HERE**".
|
||||
|
||||
8. You agree to notify the Company of any facts or circumstances of which you
|
||||
become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
Please sign: **SIGNATURE** Date: **DATE**
|
||||
@@ -24,6 +24,7 @@ import { RouteResponseFormat, routeResponseFormat } from './utils/routeUtils';
|
||||
import { parseEnv } from './env';
|
||||
import storageConnectionCheck from './utils/storageConnectionCheck';
|
||||
import { setLocale } from '@joplin/lib/locale';
|
||||
import checkAdminHandler from './middleware/checkAdminHandler';
|
||||
|
||||
interface Argv {
|
||||
env?: Env;
|
||||
@@ -160,7 +161,7 @@ async function main() {
|
||||
});
|
||||
} catch (anotherError) {
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
ctx.body = JSON.stringify({ error: error.message });
|
||||
ctx.body = JSON.stringify({ error: `${error.message} (Check the server log for more information)` });
|
||||
}
|
||||
} else {
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
@@ -196,6 +197,7 @@ async function main() {
|
||||
|
||||
app.use(apiVersionHandler);
|
||||
app.use(ownerHandler);
|
||||
app.use(checkAdminHandler);
|
||||
app.use(notificationHandler);
|
||||
app.use(clickJackingHandler);
|
||||
app.use(routeHandler);
|
||||
|
||||
@@ -66,7 +66,7 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
|
||||
enabled: env.MAILER_ENABLED,
|
||||
host: env.MAILER_HOST,
|
||||
port: env.MAILER_PORT,
|
||||
secure: env.MAILER_SECURE,
|
||||
security: env.MAILER_SECURITY,
|
||||
authUser: env.MAILER_AUTH_USER,
|
||||
authPassword: env.MAILER_AUTH_PASSWORD,
|
||||
noReplyName: env.MAILER_NOREPLY_NAME,
|
||||
@@ -120,6 +120,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
||||
stripe: stripeConfigFromEnv(stripePublicConfig, env),
|
||||
port: appPort,
|
||||
baseUrl,
|
||||
adminBaseUrl: `${baseUrl}/admin`,
|
||||
showErrorStackTraces: env.ERROR_STACK_TRACES,
|
||||
apiBaseUrl,
|
||||
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
|
||||
|
||||
46
packages/server/src/env.test.ts
Normal file
46
packages/server/src/env.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { afterAllTests, beforeAllDb, beforeEachDb, expectThrow } from './utils/testing/testUtils';
|
||||
import { parseEnv } from './env';
|
||||
|
||||
describe('env', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('env');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
it('should parse env values', async function() {
|
||||
const result = parseEnv({
|
||||
DB_CLIENT: 'pg',
|
||||
POSTGRES_PORT: '123',
|
||||
MAILER_ENABLED: 'true',
|
||||
SIGNUP_ENABLED: 'false',
|
||||
TERMS_ENABLED: '0',
|
||||
ACCOUNT_TYPES_ENABLED: '1',
|
||||
});
|
||||
|
||||
expect(result.DB_CLIENT).toBe('pg');
|
||||
expect(result.POSTGRES_PORT).toBe(123);
|
||||
expect(result.MAILER_ENABLED).toBe(true);
|
||||
expect(result.SIGNUP_ENABLED).toBe(false);
|
||||
expect(result.TERMS_ENABLED).toBe(false);
|
||||
expect(result.ACCOUNT_TYPES_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it('should overrides default values', async function() {
|
||||
expect(parseEnv({}).POSTGRES_USER).toBe('joplin');
|
||||
expect(parseEnv({}, { POSTGRES_USER: 'other' }).POSTGRES_USER).toBe('other');
|
||||
});
|
||||
|
||||
it('should validate values', async function() {
|
||||
await expectThrow(async () => parseEnv({ POSTGRES_PORT: 'notanumber' }));
|
||||
await expectThrow(async () => parseEnv({ MAILER_ENABLED: 'TRUE' }));
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
// The possible env variables and their defaults are listed below.
|
||||
//
|
||||
// The env variables can be of type string, integer or boolean. When the type is
|
||||
// boolean, set the variable to "0" or "1" in your env file.
|
||||
// boolean, set the variable to "true", "false", "0" or "1" in your env file.
|
||||
|
||||
export enum MailerSecurity {
|
||||
None = 'none',
|
||||
Tls = 'tls',
|
||||
Starttls = 'starttls',
|
||||
}
|
||||
|
||||
const defaultEnvValues: EnvVariables = {
|
||||
// ==================================================
|
||||
@@ -66,8 +72,8 @@ const defaultEnvValues: EnvVariables = {
|
||||
|
||||
MAILER_ENABLED: false,
|
||||
MAILER_HOST: '',
|
||||
MAILER_PORT: 587,
|
||||
MAILER_SECURE: true,
|
||||
MAILER_PORT: 465,
|
||||
MAILER_SECURITY: MailerSecurity.Tls,
|
||||
MAILER_AUTH_USER: '',
|
||||
MAILER_AUTH_PASSWORD: '',
|
||||
MAILER_NOREPLY_NAME: '',
|
||||
@@ -120,7 +126,7 @@ export interface EnvVariables {
|
||||
MAILER_ENABLED: boolean;
|
||||
MAILER_HOST: string;
|
||||
MAILER_PORT: number;
|
||||
MAILER_SECURE: boolean;
|
||||
MAILER_SECURITY: MailerSecurity;
|
||||
MAILER_AUTH_USER: string;
|
||||
MAILER_AUTH_PASSWORD: string;
|
||||
MAILER_NOREPLY_NAME: string;
|
||||
@@ -134,7 +140,13 @@ export interface EnvVariables {
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
}
|
||||
|
||||
export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariables {
|
||||
const parseBoolean = (s: string): boolean => {
|
||||
if (s === 'true' || s === '1') return true;
|
||||
if (s === 'false' || s === '0') return false;
|
||||
throw new Error(`Invalid boolean value: "${s}" (Must be one of "true", "false", "0, "1")`);
|
||||
};
|
||||
|
||||
export function parseEnv(rawEnv: Record<string, string>, defaultOverrides: any = null): EnvVariables {
|
||||
const output: EnvVariables = {
|
||||
...defaultEnvValues,
|
||||
...defaultOverrides,
|
||||
@@ -145,17 +157,21 @@ export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariable
|
||||
|
||||
if (rawEnvValue === undefined) continue;
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const v = Number(rawEnvValue);
|
||||
if (isNaN(v)) throw new Error(`Invalid number value for env variable ${key} = ${rawEnvValue}`);
|
||||
(output as any)[key] = v;
|
||||
} else if (typeof value === 'boolean') {
|
||||
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean value for env variable ${key}: ${rawEnvValue} (Should be either "0" or "1")`);
|
||||
(output as any)[key] = rawEnvValue === '1';
|
||||
} else if (typeof value === 'string') {
|
||||
(output as any)[key] = `${rawEnvValue}`;
|
||||
} else {
|
||||
throw new Error(`Invalid env default value type: ${typeof value}`);
|
||||
try {
|
||||
if (typeof value === 'number') {
|
||||
const v = Number(rawEnvValue);
|
||||
if (isNaN(v)) throw new Error(`Invalid number value "${rawEnvValue}"`);
|
||||
(output as any)[key] = v;
|
||||
} else if (typeof value === 'boolean') {
|
||||
(output as any)[key] = parseBoolean(rawEnvValue);
|
||||
} else if (typeof value === 'string') {
|
||||
(output as any)[key] = `${rawEnvValue}`;
|
||||
} else {
|
||||
throw new Error(`Invalid env default value type: ${typeof value}`);
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `Could not parse key "${key}": ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
packages/server/src/middleware/checkAdminHandler.test.ts
Normal file
58
packages/server/src/middleware/checkAdminHandler.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext, expectNotThrow, expectHttpError, createUserAndSession } from '../utils/testing/testUtils';
|
||||
import checkAdminHandler from './checkAdminHandler';
|
||||
|
||||
describe('checkAdminHandler', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('checkAdminHandler');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should access /admin if the user is admin', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: session.id,
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/admin/organizations',
|
||||
},
|
||||
});
|
||||
|
||||
await expectNotThrow(async () => checkAdminHandler(context, koaNext));
|
||||
});
|
||||
|
||||
test('should not access /admin if the user is not admin', async function() {
|
||||
const { session } = await createUserAndSession(1);
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: session.id,
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/admin/organizations',
|
||||
},
|
||||
});
|
||||
|
||||
await expectHttpError(async () => checkAdminHandler(context, koaNext), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
test('should not access /admin if the user is not logged in', async function() {
|
||||
const context = await koaAppContext({
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/admin/organizations',
|
||||
},
|
||||
});
|
||||
|
||||
await expectHttpError(async () => checkAdminHandler(context, koaNext), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
});
|
||||
12
packages/server/src/middleware/checkAdminHandler.ts
Normal file
12
packages/server/src/middleware/checkAdminHandler.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AppContext, KoaNext } from '../utils/types';
|
||||
import { isAdminRequest } from '../utils/requestUtils';
|
||||
import { ErrorForbidden } from '../utils/errors';
|
||||
|
||||
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||
if (isAdminRequest(ctx)) {
|
||||
if (!ctx.joplin.owner) throw new ErrorForbidden();
|
||||
if (!ctx.joplin.owner.is_admin) throw new ErrorForbidden();
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { isView, View } from '../services/MustacheService';
|
||||
import config from '../config';
|
||||
import { userIp } from '../utils/requestUtils';
|
||||
import { createCsrfTag } from '../utils/csrf';
|
||||
import { getImpersonatorAdminSessionId } from '../routes/index/utils/users/impersonate';
|
||||
import { getImpersonatorAdminSessionId } from '../routes/admin/utils/users/impersonate';
|
||||
|
||||
export default async function(ctx: AppContext) {
|
||||
const requestStartTime = Date.now();
|
||||
@@ -20,6 +20,7 @@ export default async function(ctx: AppContext) {
|
||||
const view = responseObject as View;
|
||||
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
||||
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
||||
currentUrl: ctx.URL,
|
||||
notifications: ctx.joplin.notifications || [],
|
||||
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
||||
owner: ctx.joplin.owner,
|
||||
|
||||
@@ -92,7 +92,8 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
return this.add(userId, NotificationKey.Any, NotificationLevel.Normal, message);
|
||||
}
|
||||
|
||||
public async addError(userId: Uuid, message: string) {
|
||||
public async addError(userId: Uuid, error: string | Error) {
|
||||
const message = typeof error === 'string' ? error : error.message;
|
||||
return this.add(userId, NotificationKey.Any, NotificationLevel.Error, message);
|
||||
}
|
||||
|
||||
|
||||
14
packages/server/src/routes/admin/dashboard.ts
Normal file
14
packages/server/src/routes/admin/dashboard.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import Router from '../../utils/Router';
|
||||
import { SubPath } from '../../utils/routeUtils';
|
||||
import { AppContext, RouteType } from '../../utils/types';
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('admin/dashboard', async (_path: SubPath, _ctx: AppContext) => {
|
||||
const view = defaultView('admin/dashboard', _('Admin dashboard'));
|
||||
return view;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -16,7 +16,7 @@ const prettyCron = require('prettycron');
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.post('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@@ -52,7 +52,7 @@ router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
return redirect(ctx, makeUrl(UrlType.Tasks));
|
||||
});
|
||||
|
||||
router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@@ -126,13 +126,13 @@ router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultView('tasks', 'Tasks'),
|
||||
...defaultView('admin/tasks', 'Tasks'),
|
||||
content: {
|
||||
itemTable: makeTableView(table),
|
||||
postUrl: makeUrl(UrlType.Tasks),
|
||||
csrfTag: await createCsrfTag(ctx),
|
||||
},
|
||||
cssFiles: ['index/tasks'],
|
||||
// cssFiles: ['index/tasks'],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,13 +8,13 @@ import { yesOrNo } from '../../utils/strings';
|
||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { userDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
||||
import { adminUserDeletionsUrl, userUrl } from '../../utils/urlUtils';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
@@ -26,7 +26,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
console.info(page);
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: userDeletionsUrl(),
|
||||
baseUrl: adminUserDeletionsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: page.page_count,
|
||||
pagination,
|
||||
@@ -110,7 +110,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
}),
|
||||
};
|
||||
|
||||
const view = defaultView('user_deletions', 'User deletions');
|
||||
const view = defaultView('admin/user_deletions', 'User deletions');
|
||||
view.content = {
|
||||
userDeletionTable: makeTableView(table),
|
||||
postUrl: makeUrl(UrlType.UserDeletions),
|
||||
@@ -124,7 +124,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
throw new ErrorMethodNotAllowed();
|
||||
});
|
||||
|
||||
router.post('user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const user = ctx.joplin.owner;
|
||||
if (!user.is_admin) throw new ErrorForbidden();
|
||||
|
||||
183
packages/server/src/routes/admin/users.test.ts
Normal file
183
packages/server/src/routes/admin/users.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { User } from '../../services/database/types';
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { execRequest } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
|
||||
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
password = password === null ? uuidgen() : password;
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/admin/users/new',
|
||||
body: {
|
||||
email: email,
|
||||
password: password,
|
||||
password2: password,
|
||||
post_button: true,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await routeHandler(context);
|
||||
checkContextError(context);
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: url ? url : '/admin/users',
|
||||
body: {
|
||||
...user,
|
||||
post_button: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await routeHandler(context);
|
||||
checkContextError(context);
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
describe('admin/users', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('admin/users');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create a new user', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password, {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(!!newUser).toBe(true);
|
||||
expect(!!newUser.id).toBe(true);
|
||||
expect(!!newUser.is_admin).toBe(false);
|
||||
expect(!!newUser.email).toBe(true);
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.must_set_password).toBe(0);
|
||||
|
||||
const userModel = models().user();
|
||||
const userFromModel: User = await userModel.load(newUser.id);
|
||||
|
||||
expect(!!userFromModel.password).toBe(true);
|
||||
expect(userFromModel.password === password).toBe(false); // Password has been hashed
|
||||
});
|
||||
|
||||
test('should create a user with null properties if they are not explicitly set', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com');
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.can_share_folder).toBe(null);
|
||||
expect(newUser.can_share_note).toBe(null);
|
||||
expect(newUser.max_total_item_size).toBe(null);
|
||||
});
|
||||
|
||||
test('should ask user to set password if not set on creation', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com', '', {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.must_set_password).toBe(1);
|
||||
expect(!!newUser.password).toBe(true);
|
||||
});
|
||||
|
||||
test('should format the email when saving it', async function() {
|
||||
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
|
||||
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, email, password);
|
||||
const loggedInUser = await models().user().login(email, password);
|
||||
expect(!!loggedInUser).toBe(true);
|
||||
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
|
||||
});
|
||||
|
||||
test('should not create anything if user creation fail', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const userModel = models().user();
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
|
||||
const beforeUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(2);
|
||||
|
||||
try {
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const afterUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(afterUserCount);
|
||||
});
|
||||
|
||||
test('should list users', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
const { user: user2 } = await createUserAndSession(2, false);
|
||||
|
||||
const result = await execRequest(session1.id, 'GET', 'admin/users');
|
||||
expect(result).toContain(user1.email);
|
||||
expect(result).toContain(user2.email);
|
||||
});
|
||||
|
||||
test('should delete sessions when changing password', async function() {
|
||||
const { user, session, password } = await createUserAndSession(1);
|
||||
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
|
||||
expect(await models().session().count()).toBe(4);
|
||||
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
password: 'hunter11hunter22hunter33',
|
||||
password2: 'hunter11hunter22hunter33',
|
||||
}, '/admin/users/me');
|
||||
|
||||
const sessions = await models().session().all();
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(session.id);
|
||||
});
|
||||
|
||||
test('should apply ACL', async function() {
|
||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
||||
|
||||
// admin user cannot make themselves a non-admin
|
||||
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
|
||||
|
||||
// cannot delete own user
|
||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `admin/users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
});
|
||||
311
packages/server/src/routes/admin/users.ts
Normal file
311
packages/server/src/routes/admin/users.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { SubPath, redirect } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
|
||||
import config from '../../config';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
|
||||
import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { formatDateTime, Hour } from '../../utils/time';
|
||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
||||
import { userFlagToString } from '../../models/UserFlagModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
password2: string;
|
||||
}
|
||||
|
||||
export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required: boolean): string {
|
||||
if (fields.password) {
|
||||
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
|
||||
return fields.password;
|
||||
} else {
|
||||
if (required) throw new ErrorUnprocessableEntity('Password is required');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function makeUser(isNew: boolean, fields: any): User {
|
||||
const user: User = {};
|
||||
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
|
||||
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
|
||||
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
|
||||
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
|
||||
if ('account_type' in fields) user.account_type = Number(fields.account_type);
|
||||
|
||||
const password = checkRepeatPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
if (isNew) {
|
||||
user.must_set_password = user.password ? 0 : 1;
|
||||
user.password = user.password ? user.password : uuidgen();
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function defaultUser(): User {
|
||||
return {};
|
||||
}
|
||||
|
||||
function userIsNew(path: SubPath): boolean {
|
||||
return path.id === 'new';
|
||||
}
|
||||
|
||||
function userIsMe(path: SubPath): boolean {
|
||||
return path.id === 'me';
|
||||
}
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
|
||||
const userModel = ctx.joplin.models.user();
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
|
||||
|
||||
const users = await userModel.all();
|
||||
|
||||
users.sort((u1: User, u2: User) => {
|
||||
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
|
||||
if (u1.full_name && !u2.full_name) return +1;
|
||||
if (!u1.full_name && u2.full_name) return -1;
|
||||
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
const view: View = defaultView('admin/users', _('Users'));
|
||||
view.content = {
|
||||
users: users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
url: adminUserUrl(user.id),
|
||||
displayName: user.full_name ? user.full_name : '(not set)',
|
||||
formattedItemMaxSize: formatMaxItemSize(user),
|
||||
formattedTotalSize: formatTotalSize(user),
|
||||
formattedMaxTotalSize: formatMaxTotalSize(user),
|
||||
formattedTotalSizePercent: formatTotalSizePercent(user),
|
||||
totalSizeClass: totalSizeClass(user),
|
||||
formattedAccountType: accountTypeToString(user.account_type),
|
||||
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
|
||||
rowClassName: user.enabled ? '' : 'is-disabled',
|
||||
};
|
||||
}),
|
||||
};
|
||||
return view;
|
||||
});
|
||||
|
||||
router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
|
||||
const owner = ctx.joplin.owner;
|
||||
const isMe = userIsMe(path);
|
||||
const isNew = userIsNew(path);
|
||||
const models = ctx.joplin.models;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
user = !isNew ? user || await models.user().load(userId) : user;
|
||||
if (isNew && !user) user = defaultUser();
|
||||
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
||||
|
||||
let postUrl = '';
|
||||
|
||||
if (isNew) {
|
||||
postUrl = adminUserUrl('new');
|
||||
} else if (isMe) {
|
||||
postUrl = adminUserUrl('me');
|
||||
} else {
|
||||
postUrl = adminUserUrl(user.id);
|
||||
}
|
||||
|
||||
interface UserFlagView extends UserFlag {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
return {
|
||||
...f,
|
||||
message: userFlagToString(f),
|
||||
};
|
||||
});
|
||||
|
||||
userFlagViews.sort((a, b) => {
|
||||
return a.created_time < b.created_time ? +1 : -1;
|
||||
});
|
||||
|
||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
||||
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
|
||||
|
||||
const view: View = defaultView('admin/user', _('Profile'));
|
||||
view.content.user = user;
|
||||
view.content.isNew = isNew;
|
||||
view.content.buttonTitle = isNew ? _('Create user') : _('Update profile');
|
||||
view.content.error = error;
|
||||
view.content.postUrl = postUrl;
|
||||
view.content.showDisableButton = !isNew && owner.id !== user.id && user.enabled;
|
||||
view.content.csrfTag = await createCsrfTag(ctx);
|
||||
|
||||
if (subscription) {
|
||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||
|
||||
view.content.subscription = subscription;
|
||||
view.content.showManageSubscription = !isNew;
|
||||
view.content.showUpdateSubscriptionBasic = !isNew && user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
||||
}
|
||||
|
||||
view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id;
|
||||
view.content.showRestoreButton = !isNew && !user.enabled;
|
||||
view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion;
|
||||
view.content.showResetPasswordButton = !isNew && user.enabled;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
|
||||
view.content.hasFlags = !!userFlagViews.length;
|
||||
view.content.userFlagViews = userFlagViews;
|
||||
view.content.stripePortalUrl = stripePortalUrl();
|
||||
view.content.pageTitle = view.content.buttonTitle;
|
||||
|
||||
view.jsFiles.push('zxcvbn');
|
||||
view.cssFiles.push('index/user');
|
||||
|
||||
if (config().accountTypesEnabled) {
|
||||
view.content.showAccountTypes = true;
|
||||
view.content.accountTypes = accountTypeOptions().map((o: any) => {
|
||||
o.selected = user.account_type === o.value;
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
});
|
||||
|
||||
router.alias(HttpMethod.POST, 'admin/users/:id', 'admin/users');
|
||||
|
||||
interface FormFields {
|
||||
id: Uuid;
|
||||
post_button: string;
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_account_confirmation_email: string;
|
||||
update_subscription_basic_button: string;
|
||||
update_subscription_pro_button: string;
|
||||
impersonate_button: string;
|
||||
stop_impersonate_button: string;
|
||||
delete_user_flags: string;
|
||||
schedule_deletion_button: string;
|
||||
}
|
||||
|
||||
router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
|
||||
let user: User = {};
|
||||
const owner = ctx.joplin.owner;
|
||||
let userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
const fields = body.fields as FormFields;
|
||||
const isNew = userIsNew(path);
|
||||
if (userIsMe(path)) fields.id = userId;
|
||||
user = makeUser(isNew, fields);
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
const savedUser = await models.user().save(userToSave);
|
||||
userId = savedUser.id;
|
||||
} else {
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
}
|
||||
} else if (fields.stop_impersonate_button) {
|
||||
await stopImpersonating(ctx);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_account_confirmation_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.impersonate_button) {
|
||||
await startImpersonating(ctx, userId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else if (fields.schedule_deletion_button) {
|
||||
const deletionDate = Date.now() + 24 * Hour;
|
||||
|
||||
await models.userDeletion().add(userId, deletionDate, {
|
||||
processAccount: true,
|
||||
processData: true,
|
||||
});
|
||||
|
||||
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${adminUserDeletionsUrl()})`);
|
||||
} else if (fields.delete_user_flags) {
|
||||
const userFlagTypes: UserFlagType[] = [];
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (key.startsWith('user_flag_')) {
|
||||
const type = Number(key.substr(10));
|
||||
userFlagTypes.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
await models.userFlag().removeMulti(userId, userFlagTypes);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
|
||||
return redirect(ctx, adminUserUrl(userIsMe(path) ? '/me' : `/${userId}`));
|
||||
} catch (error) {
|
||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||
if (error instanceof ErrorForbidden) throw error;
|
||||
const endPoint = router.findEndPoint(HttpMethod.GET, 'admin/users/:id');
|
||||
return endPoint.handler(path, ctx, user, error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -34,6 +34,7 @@ router.get('api/users/:id/public_key', async (path: SubPath, ctx: AppContext) =>
|
||||
if (!user) return ''; // Don't throw an error to prevent polling the end point
|
||||
|
||||
const ppk = await ctx.joplin.models.user().publicPrivateKey(user.id);
|
||||
if (!ppk) return '';
|
||||
|
||||
return {
|
||||
id: ppk.id,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
|
||||
|
||||
describe('index_home', function() {
|
||||
describe('index/home', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('index_home');
|
||||
await beforeAllDb('index/home');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -7,14 +7,14 @@ import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
|
||||
export async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
|
||||
password = password === null ? uuidgen() : password;
|
||||
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/users/new',
|
||||
url: '/admin/users/new',
|
||||
body: {
|
||||
email: email,
|
||||
password: password,
|
||||
@@ -30,7 +30,7 @@ export async function postUser(sessionId: string, email: string, password: strin
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
export async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
@@ -48,7 +48,7 @@ export async function patchUser(sessionId: string, user: any, url: string = ''):
|
||||
return context.response.body;
|
||||
}
|
||||
|
||||
export async function getUserHtml(sessionId: string, userId: string): Promise<string> {
|
||||
async function getUserHtml(sessionId: string, userId: string): Promise<string> {
|
||||
const context = await koaAppContext({
|
||||
sessionId: sessionId,
|
||||
request: {
|
||||
@@ -76,53 +76,6 @@ describe('index/users', function() {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create a new user', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password, {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(!!newUser).toBe(true);
|
||||
expect(!!newUser.id).toBe(true);
|
||||
expect(!!newUser.is_admin).toBe(false);
|
||||
expect(!!newUser.email).toBe(true);
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.must_set_password).toBe(0);
|
||||
|
||||
const userModel = models().user();
|
||||
const userFromModel: User = await userModel.load(newUser.id);
|
||||
|
||||
expect(!!userFromModel.password).toBe(true);
|
||||
expect(userFromModel.password === password).toBe(false); // Password has been hashed
|
||||
});
|
||||
|
||||
test('should create a user with null properties if they are not explicitly set', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com');
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.max_item_size).toBe(null);
|
||||
expect(newUser.can_share_folder).toBe(null);
|
||||
expect(newUser.can_share_note).toBe(null);
|
||||
expect(newUser.max_total_item_size).toBe(null);
|
||||
});
|
||||
|
||||
test('should ask user to set password if not set on creation', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com', '', {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.must_set_password).toBe(1);
|
||||
expect(!!newUser.password).toBe(true);
|
||||
});
|
||||
|
||||
test('new user should be able to login', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
@@ -133,39 +86,6 @@ describe('index/users', function() {
|
||||
expect(loggedInUser.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
test('should format the email when saving it', async function() {
|
||||
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
|
||||
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, email, password);
|
||||
const loggedInUser = await models().user().login(email, password);
|
||||
expect(!!loggedInUser).toBe(true);
|
||||
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
|
||||
});
|
||||
|
||||
test('should not create anything if user creation fail', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
const userModel = models().user();
|
||||
|
||||
const password = uuidgen();
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
|
||||
const beforeUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(2);
|
||||
|
||||
try {
|
||||
await postUser(session.id, 'test@example.com', password);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const afterUserCount = (await userModel.all()).length;
|
||||
expect(beforeUserCount).toBe(afterUserCount);
|
||||
});
|
||||
|
||||
test('should change user properties', async function() {
|
||||
const { user, session } = await createUserAndSession(1, false);
|
||||
|
||||
@@ -198,15 +118,6 @@ describe('index/users', function() {
|
||||
expect((doc.querySelector('input[name=email]') as any).value).toBe('user1@localhost');
|
||||
});
|
||||
|
||||
test('should list users', async function() {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
const { user: user2 } = await createUserAndSession(2, false);
|
||||
|
||||
const result = await execRequest(session1.id, 'GET', 'users');
|
||||
expect(result).toContain(user1.email);
|
||||
expect(result).toContain(user2.email);
|
||||
});
|
||||
|
||||
test('should allow user to set a password for new accounts', async function() {
|
||||
let user1 = await models().user().save({
|
||||
email: 'user1@localhost',
|
||||
@@ -366,33 +277,31 @@ describe('index/users', function() {
|
||||
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
|
||||
});
|
||||
|
||||
test('should delete sessions when changing password', async function() {
|
||||
const { user, session, password } = await createUserAndSession(1);
|
||||
test('should not change non-whitelisted properties', async () => {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
await models().session().authenticate(user.email, password);
|
||||
|
||||
expect(await models().session().count()).toBe(4);
|
||||
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
password: 'hunter11hunter22hunter33',
|
||||
password2: 'hunter11hunter22hunter33',
|
||||
}, '/users/me');
|
||||
|
||||
const sessions = await models().session().all();
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(session.id);
|
||||
await patchUser(session1.id, {
|
||||
id: user1.id,
|
||||
is_admin: 1,
|
||||
max_item_size: 555,
|
||||
max_total_item_size: 5555,
|
||||
can_share_folder: 1,
|
||||
can_upload: 0,
|
||||
});
|
||||
const reloadedUser1 = await models().user().load(user1.id);
|
||||
expect(reloadedUser1.is_admin).toBe(0);
|
||||
expect(reloadedUser1.max_item_size).toBe(null);
|
||||
expect(reloadedUser1.max_total_item_size).toBe(null);
|
||||
expect(reloadedUser1.can_share_folder).toBe(null);
|
||||
expect(reloadedUser1.can_upload).toBe(1);
|
||||
});
|
||||
|
||||
test('should apply ACL', async function() {
|
||||
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
|
||||
const { user: user1, session: session1 } = await createUserAndSession(2, false);
|
||||
const { user: admin } = await createUserAndSession(1, true);
|
||||
const { session: session1 } = await createUserAndSession(2, false);
|
||||
|
||||
// non-admin cannot list users
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', 'users'), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', 'admin/users'), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin user cannot view another user
|
||||
await expectHttpError(async () => execRequest(session1.id, 'GET', `users/${admin.id}`), ErrorForbidden.httpCode);
|
||||
@@ -402,30 +311,6 @@ describe('index/users', function() {
|
||||
|
||||
// non-admin user cannot update another user
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin user cannot make themself an admin
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, is_admin: 1 }), ErrorForbidden.httpCode);
|
||||
|
||||
// admin user cannot make themselves a non-admin
|
||||
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
|
||||
|
||||
// only admins can delete users
|
||||
// Note: Disabled because the entire code is skipped if it's not an admin
|
||||
// await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
|
||||
// cannot delete own user
|
||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change max_item_size
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_total_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change can_share_folder
|
||||
await models().user().save({ id: user1.id, can_share_folder: 0 });
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share_folder: 1 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change non-whitelisted properties
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_upload: 0 }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -4,24 +4,21 @@ import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
|
||||
import { User, UserFlag, Uuid } from '../../services/database/types';
|
||||
import config from '../../config';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
|
||||
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
|
||||
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
|
||||
import { confirmUrl, stripePortalUrl, userDeletionsUrl } from '../../utils/urlUtils';
|
||||
import { cancelSubscriptionByUserId, updateCustomerEmail, updateSubscriptionType } from '../../utils/stripe';
|
||||
import { AccountType, accountTypeOptions } from '../../models/UserModel';
|
||||
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
|
||||
import { updateCustomerEmail } from '../../utils/stripe';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { formatDateTime, Hour } from '../../utils/time';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { cookieSet } from '../../utils/cookies';
|
||||
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
|
||||
import { userFlagToString } from '../../models/UserFlagModel';
|
||||
import { stopImpersonating } from '../admin/utils/users/impersonate';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export interface CheckRepeatPasswordInput {
|
||||
password: string;
|
||||
@@ -39,120 +36,40 @@ export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required:
|
||||
return '';
|
||||
}
|
||||
|
||||
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
|
||||
if (fields[fieldName] === '') return null;
|
||||
const output = Number(fields[fieldName]);
|
||||
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function makeUser(isNew: boolean, fields: any): User {
|
||||
function makeUser(userId: Uuid, fields: any): User {
|
||||
const user: User = {};
|
||||
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
|
||||
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
|
||||
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
|
||||
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
|
||||
if ('account_type' in fields) user.account_type = Number(fields.account_type);
|
||||
|
||||
const password = checkRepeatPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
if (isNew) {
|
||||
user.must_set_password = user.password ? 0 : 1;
|
||||
user.password = user.password ? user.password : uuidgen();
|
||||
}
|
||||
user.id = userId;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function defaultUser(): User {
|
||||
return {};
|
||||
}
|
||||
|
||||
function userIsNew(path: SubPath): boolean {
|
||||
return path.id === 'new';
|
||||
}
|
||||
|
||||
function userIsMe(path: SubPath): boolean {
|
||||
return path.id === 'me';
|
||||
}
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
||||
const userModel = ctx.joplin.models.user();
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
|
||||
|
||||
const users = await userModel.all();
|
||||
|
||||
users.sort((u1: User, u2: User) => {
|
||||
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
|
||||
if (u1.full_name && !u2.full_name) return +1;
|
||||
if (!u1.full_name && u2.full_name) return -1;
|
||||
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
const view: View = defaultView('users', 'Users');
|
||||
view.content = {
|
||||
users: users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
displayName: user.full_name ? user.full_name : '(not set)',
|
||||
formattedItemMaxSize: formatMaxItemSize(user),
|
||||
formattedTotalSize: formatTotalSize(user),
|
||||
formattedMaxTotalSize: formatMaxTotalSize(user),
|
||||
formattedTotalSizePercent: formatTotalSizePercent(user),
|
||||
totalSizeClass: totalSizeClass(user),
|
||||
formattedAccountType: accountTypeToString(user.account_type),
|
||||
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
|
||||
rowClassName: user.enabled ? '' : 'is-disabled',
|
||||
};
|
||||
}),
|
||||
userDeletionUrl: userDeletionsUrl(),
|
||||
};
|
||||
return view;
|
||||
});
|
||||
|
||||
router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
|
||||
router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User = null, error: any = null) => {
|
||||
const owner = ctx.joplin.owner;
|
||||
const isMe = userIsMe(path);
|
||||
const isNew = userIsNew(path);
|
||||
const models = ctx.joplin.models;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
user = !isNew ? user || await models.user().load(userId) : null;
|
||||
if (isNew && !user) user = defaultUser();
|
||||
const models = ctx.joplin.models;
|
||||
const userId = owner.id;
|
||||
|
||||
const user = await models.user().load(userId);
|
||||
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
|
||||
|
||||
let postUrl = '';
|
||||
|
||||
if (isNew) {
|
||||
postUrl = `${config().baseUrl}/users/new`;
|
||||
} else if (isMe) {
|
||||
postUrl = `${config().baseUrl}/users/me`;
|
||||
} else {
|
||||
postUrl = `${config().baseUrl}/users/${user.id}`;
|
||||
}
|
||||
const postUrl = `${config().baseUrl}/users/me`;
|
||||
|
||||
interface UserFlagView extends UserFlag {
|
||||
message: string;
|
||||
}
|
||||
|
||||
let userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
let userFlagViews: UserFlagView[] = (await models.userFlag().allByUserId(user.id)).map(f => {
|
||||
return {
|
||||
...f,
|
||||
message: userFlagToString(f),
|
||||
@@ -165,35 +82,25 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
|
||||
if (!owner.is_admin) userFlagViews = [];
|
||||
|
||||
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
|
||||
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
|
||||
const subscription = await ctx.joplin.models.subscription().byUserId(userId);
|
||||
|
||||
const view: View = defaultView('user', 'Profile');
|
||||
view.content.user = user;
|
||||
view.content.isNew = isNew;
|
||||
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
|
||||
view.content.user = formUser ? formUser : user;
|
||||
view.content.buttonTitle = _('Update profile');
|
||||
view.content.error = error;
|
||||
view.content.postUrl = postUrl;
|
||||
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
|
||||
view.content.csrfTag = await createCsrfTag(ctx);
|
||||
|
||||
if (subscription) {
|
||||
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
|
||||
|
||||
view.content.subscription = subscription;
|
||||
view.content.showManageSubscription = !isNew;
|
||||
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
|
||||
view.content.showUpdateSubscriptionBasic = user.account_type !== AccountType.Basic;
|
||||
view.content.showUpdateSubscriptionPro = user.account_type !== AccountType.Pro;
|
||||
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
|
||||
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
|
||||
}
|
||||
|
||||
view.content.showImpersonateButton = !isNew && !!owner.is_admin && user.enabled && user.id !== owner.id;
|
||||
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
|
||||
view.content.showScheduleDeletionButton = !isNew && !!owner.is_admin && !isScheduledForDeletion;
|
||||
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
|
||||
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
|
||||
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
|
||||
view.content.hasFlags = !!userFlagViews.length;
|
||||
view.content.userFlagViews = userFlagViews;
|
||||
view.content.stripePortalUrl = stripePortalUrl();
|
||||
@@ -303,98 +210,50 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
|
||||
interface FormFields {
|
||||
id: Uuid;
|
||||
post_button: string;
|
||||
disable_button: string;
|
||||
restore_button: string;
|
||||
cancel_subscription_button: string;
|
||||
send_account_confirmation_email: string;
|
||||
update_subscription_basic_button: string;
|
||||
update_subscription_pro_button: string;
|
||||
// user_cancel_subscription_button: string;
|
||||
impersonate_button: string;
|
||||
stop_impersonate_button: string;
|
||||
delete_user_flags: string;
|
||||
schedule_deletion_button: string;
|
||||
}
|
||||
|
||||
router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
let user: User = {};
|
||||
const owner = ctx.joplin.owner;
|
||||
const userId = userIsMe(path) ? owner.id : path.id;
|
||||
|
||||
if (path.id && path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
let user: User = null;
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
const fields = body.fields as FormFields;
|
||||
const isNew = userIsNew(path);
|
||||
if (userIsMe(path)) fields.id = userId;
|
||||
user = makeUser(isNew, fields);
|
||||
|
||||
const models = ctx.joplin.models;
|
||||
if (fields.id && fields.id !== owner.id) throw new ErrorForbidden();
|
||||
|
||||
user = makeUser(owner.id, fields);
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
await models.user().save(userToSave);
|
||||
} else {
|
||||
if (userToSave.email && !owner.is_admin) {
|
||||
await models.user().initiateEmailChange(userId, userToSave.email);
|
||||
delete userToSave.email;
|
||||
}
|
||||
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
if (userToSave.email && userToSave.email !== owner.email) {
|
||||
await models.user().initiateEmailChange(owner.id, userToSave.email);
|
||||
delete userToSave.email;
|
||||
}
|
||||
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
|
||||
// When changing the password, we also clear all session IDs for
|
||||
// that user, except the current one (otherwise they would be
|
||||
// logged out).
|
||||
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
|
||||
} else if (fields.stop_impersonate_button) {
|
||||
await stopImpersonating(ctx);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (owner.is_admin) {
|
||||
if (fields.disable_button || fields.restore_button) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
|
||||
} else if (fields.send_account_confirmation_email) {
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.impersonate_button) {
|
||||
await startImpersonating(ctx, userId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else if (fields.schedule_deletion_button) {
|
||||
const deletionDate = Date.now() + 24 * Hour;
|
||||
|
||||
await models.userDeletion().add(userId, deletionDate, {
|
||||
processAccount: true,
|
||||
processData: true,
|
||||
});
|
||||
|
||||
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${userDeletionsUrl()})`);
|
||||
} else if (fields.delete_user_flags) {
|
||||
const userFlagTypes: UserFlagType[] = [];
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (key.startsWith('user_flag_')) {
|
||||
const type = Number(key.substr(10));
|
||||
userFlagTypes.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
await models.userFlag().removeMulti(userId, userFlagTypes);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
|
||||
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
|
||||
return redirect(ctx, `${config().baseUrl}/users/me`);
|
||||
} catch (error) {
|
||||
error.message = `Error: Your changes were not saved: ${error.message}`;
|
||||
if (error instanceof ErrorForbidden) throw error;
|
||||
|
||||
@@ -5,12 +5,17 @@ import apiBatchItems from './api/batch_items';
|
||||
import apiDebug from './api/debug';
|
||||
import apiEvents from './api/events';
|
||||
import apiItems from './api/items';
|
||||
import apiLocks from './api/locks';
|
||||
import apiPing from './api/ping';
|
||||
import apiSessions from './api/sessions';
|
||||
import apiShares from './api/shares';
|
||||
import apiShareUsers from './api/share_users';
|
||||
import apiUsers from './api/users';
|
||||
import apiLocks from './api/locks';
|
||||
|
||||
import adminDashboard from './admin/dashboard';
|
||||
import adminTasks from './admin/tasks';
|
||||
import adminUserDeletions from './admin/user_deletions';
|
||||
import adminUsers from './admin/users';
|
||||
|
||||
import indexChanges from './index/changes';
|
||||
import indexHelp from './index/help';
|
||||
@@ -24,44 +29,45 @@ import indexPrivacy from './index/privacy';
|
||||
import indexShares from './index/shares';
|
||||
import indexSignup from './index/signup';
|
||||
import indexStripe from './index/stripe';
|
||||
import indexTasks from './index/tasks';
|
||||
import indexTerms from './index/terms';
|
||||
import indexUpgrade from './index/upgrade';
|
||||
import indexUsers from './index/users';
|
||||
import indexUserDeletions from './index/user_deletions';
|
||||
|
||||
import defaultRoute from './default';
|
||||
|
||||
const routes: Routers = {
|
||||
'api/batch': apiBatch,
|
||||
'api/batch_items': apiBatchItems,
|
||||
'api/batch': apiBatch,
|
||||
'api/debug': apiDebug,
|
||||
'api/events': apiEvents,
|
||||
'api/items': apiItems,
|
||||
'api/locks': apiLocks,
|
||||
'api/ping': apiPing,
|
||||
'api/sessions': apiSessions,
|
||||
'api/share_users': apiShareUsers,
|
||||
'api/shares': apiShares,
|
||||
'api/users': apiUsers,
|
||||
'api/locks': apiLocks,
|
||||
|
||||
'admin/dashboard': adminDashboard,
|
||||
'admin/tasks': adminTasks,
|
||||
'admin/user_deletions': adminUserDeletions,
|
||||
'admin/users': adminUsers,
|
||||
|
||||
'changes': indexChanges,
|
||||
'help': indexHelp,
|
||||
'home': indexHome,
|
||||
'items': indexItems,
|
||||
'password': indexPassword,
|
||||
'login': indexLogin,
|
||||
'logout': indexLogout,
|
||||
'notifications': indexNotifications,
|
||||
'signup': indexSignup,
|
||||
'password': indexPassword,
|
||||
'privacy': indexPrivacy,
|
||||
'shares': indexShares,
|
||||
'users': indexUsers,
|
||||
'signup': indexSignup,
|
||||
'stripe': indexStripe,
|
||||
'terms': indexTerms,
|
||||
'privacy': indexPrivacy,
|
||||
'upgrade': indexUpgrade,
|
||||
'help': indexHelp,
|
||||
'tasks': indexTasks,
|
||||
'user_deletions': indexUserDeletions,
|
||||
'users': indexUsers,
|
||||
|
||||
'': defaultRoute,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Email, EmailSender } from '../services/database/types';
|
||||
import { errorToString } from '../utils/errors';
|
||||
import EmailModel from '../models/EmailModel';
|
||||
import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils';
|
||||
import { MailerSecurity } from '../env';
|
||||
|
||||
const logger = Logger.create('EmailService');
|
||||
|
||||
@@ -25,10 +26,16 @@ export default class EmailService extends BaseService {
|
||||
if (!this.senderInfo(EmailSender.NoReply).email) {
|
||||
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
|
||||
}
|
||||
|
||||
// NodeMailer's TLS options are weird:
|
||||
// https://nodemailer.com/smtp/#tls-options
|
||||
|
||||
const options: SMTPTransport.Options = {
|
||||
host: this.config.mailer.host,
|
||||
port: this.config.mailer.port,
|
||||
secure: this.config.mailer.secure,
|
||||
secure: this.config.mailer.security === MailerSecurity.Tls,
|
||||
ignoreTLS: this.config.mailer.security === MailerSecurity.None,
|
||||
requireTLS: this.config.mailer.security === MailerSecurity.Starttls,
|
||||
};
|
||||
if (this.config.mailer.authUser || this.config.mailer.authPassword) {
|
||||
options.auth = {
|
||||
|
||||
@@ -9,6 +9,19 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
|
||||
import MarkdownIt = require('markdown-it');
|
||||
import { headerAnchor } from '@joplin/renderer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
|
||||
import { URL } from 'url';
|
||||
|
||||
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
|
||||
|
||||
export interface MenuItem {
|
||||
title: string;
|
||||
url?: string;
|
||||
children?: MenuItem[];
|
||||
selected?: boolean;
|
||||
icon?: string;
|
||||
selectedCondition?: MenuItemSelectedCondition;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
partials?: any;
|
||||
@@ -27,6 +40,7 @@ export interface View {
|
||||
partials?: string[];
|
||||
cssFiles?: string[];
|
||||
jsFiles?: string[];
|
||||
strings?: Record<string, string>; // List of translatable strings
|
||||
}
|
||||
|
||||
interface GlobalParams {
|
||||
@@ -46,6 +60,11 @@ interface GlobalParams {
|
||||
isJoplinCloud?: boolean;
|
||||
impersonatorAdminSessionId?: string;
|
||||
csrfTag?: string;
|
||||
s?: Record<string, string>; // List of translatable strings
|
||||
isAdminPage?: boolean;
|
||||
adminMenu?: MenuItem[];
|
||||
navbarMenu?: MenuItem[];
|
||||
currentUrl?: URL;
|
||||
}
|
||||
|
||||
export function isView(o: any): boolean {
|
||||
@@ -93,6 +112,88 @@ export default class MustacheService {
|
||||
return `${config().layoutDir}/${name}.mustache`;
|
||||
}
|
||||
|
||||
private setSelectedMenu(selectedUrl: URL, menuItems: MenuItem[]) {
|
||||
if (!selectedUrl) return;
|
||||
if (!menuItems) return;
|
||||
|
||||
const url = stripOffQueryParameters(selectedUrl.href);
|
||||
|
||||
for (const menuItem of menuItems) {
|
||||
if (menuItem.url) {
|
||||
if (menuItem.selectedCondition) {
|
||||
menuItem.selected = menuItem.selectedCondition(selectedUrl);
|
||||
} else {
|
||||
menuItem.selected = url === menuItem.url;
|
||||
}
|
||||
}
|
||||
this.setSelectedMenu(selectedUrl, menuItem.children);
|
||||
}
|
||||
}
|
||||
|
||||
private makeAdminMenu(selectedUrl: URL): MenuItem[] {
|
||||
const output: MenuItem[] = [
|
||||
{
|
||||
title: _('General'),
|
||||
children: [
|
||||
{
|
||||
title: _('Dashboard'),
|
||||
url: adminDashboardUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Users'),
|
||||
url: adminUsersUrl(),
|
||||
},
|
||||
{
|
||||
title: _('User deletions'),
|
||||
url: adminUserDeletionsUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Tasks'),
|
||||
url: adminTasksUrl(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
this.setSelectedMenu(selectedUrl, output);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
|
||||
let output: MenuItem[] = [
|
||||
{
|
||||
title: _('Home'),
|
||||
url: homeUrl(),
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
output = output.concat([
|
||||
{
|
||||
title: _('Items'),
|
||||
url: itemsUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Logs'),
|
||||
url: changesUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Admin'),
|
||||
url: adminDashboardUrl(),
|
||||
icon: 'fas fa-hammer',
|
||||
selectedCondition: (selectedUrl: URL) => {
|
||||
return selectedUrl.pathname.startsWith('/admin/') || selectedUrl.pathname === '/admin';
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.setSelectedMenu(selectedUrl, output);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private get defaultLayoutOptions(): GlobalParams {
|
||||
return {
|
||||
baseUrl: config().baseUrl,
|
||||
@@ -185,13 +286,10 @@ export default class MustacheService {
|
||||
globalParams = {
|
||||
...this.defaultLayoutOptions,
|
||||
...globalParams,
|
||||
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
|
||||
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
|
||||
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
|
||||
};
|
||||
|
||||
const contentHtml = await this.renderFileContent(filePath, view, globalParams);
|
||||
|
||||
const layoutView: any = {
|
||||
global: globalParams,
|
||||
isAdminPage: view.path.startsWith('/admin/'),
|
||||
s: {
|
||||
home: _('Home'),
|
||||
users: _('Users'),
|
||||
@@ -200,7 +298,14 @@ export default class MustacheService {
|
||||
tasks: _('Tasks'),
|
||||
help: _('Help'),
|
||||
logout: _('Logout'),
|
||||
admin: _('Admin'),
|
||||
},
|
||||
};
|
||||
|
||||
const contentHtml = await this.renderFileContent(filePath, view, globalParams);
|
||||
|
||||
const layoutView: any = {
|
||||
global: globalParams,
|
||||
pageName: view.name,
|
||||
pageTitle: view.titleOverride ? view.title : `${config().appName} - ${view.title}`,
|
||||
contentHtml: contentHtml,
|
||||
|
||||
@@ -2,9 +2,11 @@ import { View } from '../services/MustacheService';
|
||||
|
||||
// Populate a View object with some good defaults.
|
||||
export default function(name: string, title: string): View {
|
||||
const pathPrefix = name.startsWith('admin/') ? '' : 'index/';
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: `index/${name}`,
|
||||
path: `${pathPrefix}/${name}`,
|
||||
content: {},
|
||||
navbar: true,
|
||||
title: title,
|
||||
|
||||
@@ -71,6 +71,10 @@ export function isApiRequest(ctx: AppContext): boolean {
|
||||
return ctx.path.indexOf('/api/') === 0;
|
||||
}
|
||||
|
||||
export function isAdminRequest(ctx: AppContext): boolean {
|
||||
return ctx.path.indexOf('/admin/') === 0;
|
||||
}
|
||||
|
||||
export function userIp(ctx: AppContext): string {
|
||||
if (ctx.headers['x-real-ip']) return ctx.headers['x-real-ip'];
|
||||
return ctx.ip;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
||||
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
||||
import { ItemAddressingType } from '../services/database/types';
|
||||
import { RouteType } from './types';
|
||||
import { expectThrow } from './testing/testUtils';
|
||||
|
||||
describe('routeUtils', function() {
|
||||
|
||||
@@ -26,6 +27,61 @@ describe('routeUtils', function() {
|
||||
}
|
||||
});
|
||||
|
||||
it('should find a matching route', async function() {
|
||||
const testCases: any[] = [
|
||||
['/admin/organizations', {
|
||||
route: 1,
|
||||
basePath: 'admin/organizations',
|
||||
subPath: {
|
||||
id: '',
|
||||
link: '',
|
||||
addressingType: 1,
|
||||
raw: '',
|
||||
schema: 'admin/organizations',
|
||||
},
|
||||
}],
|
||||
|
||||
['/api/users/123', {
|
||||
route: 2,
|
||||
basePath: 'api/users',
|
||||
subPath: {
|
||||
id: '123',
|
||||
link: '',
|
||||
addressingType: 1,
|
||||
raw: '123',
|
||||
schema: 'api/users/:id',
|
||||
},
|
||||
}],
|
||||
|
||||
['/help', {
|
||||
route: 3,
|
||||
basePath: 'help',
|
||||
subPath: {
|
||||
id: '',
|
||||
link: '',
|
||||
addressingType: 1,
|
||||
raw: '',
|
||||
schema: 'help',
|
||||
},
|
||||
}],
|
||||
];
|
||||
|
||||
const routes: Record<string, any> = {
|
||||
'admin/organizations': 1,
|
||||
'api/users': 2,
|
||||
'help': 3,
|
||||
};
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const [path, expected] = testCase;
|
||||
const actual = findMatchingRoute(path, routes);
|
||||
expect(actual).toEqual(expected);
|
||||
}
|
||||
|
||||
await expectThrow(async () => findMatchingRoute('help', routes));
|
||||
await expectThrow(async () => findMatchingRoute('api/users/123', routes));
|
||||
});
|
||||
|
||||
it('should split an item path', async function() {
|
||||
const testCases: any[] = [
|
||||
['root:/Documents/MyFile.md:', ['root', 'Documents', 'MyFile.md']],
|
||||
|
||||
@@ -223,6 +223,10 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
|
||||
// - The ID: "SOME_ID"
|
||||
// - The link: "content"
|
||||
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
|
||||
// Enforce that path starts with "/" because if it doesn't, the function
|
||||
// will return strange but valid results.
|
||||
if (path.length && path[0] !== '/') throw new Error(`Expected path to start with "/": ${path}`);
|
||||
|
||||
const splittedPath = path.split('/');
|
||||
|
||||
// Because the path starts with "/", we remove the first element, which is
|
||||
|
||||
@@ -24,6 +24,7 @@ import uuidgen from '../uuidgen';
|
||||
import { createCsrfToken } from '../csrf';
|
||||
import { cookieSet } from '../cookies';
|
||||
import { parseEnv } from '../../env';
|
||||
import { URL } from 'url';
|
||||
|
||||
// Takes into account the fact that this file will be inside the /dist directory
|
||||
// when it runs.
|
||||
@@ -218,7 +219,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
|
||||
query: req.query,
|
||||
method: req.method,
|
||||
redirect: () => {},
|
||||
URL: { origin: config().baseUrl },
|
||||
URL: new URL(config().baseUrl), // origin
|
||||
};
|
||||
|
||||
if (options.sessionId) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Account } from '../models/UserModel';
|
||||
import { Services } from '../services/types';
|
||||
import { Routers } from './routeUtils';
|
||||
import { DbConnection } from '../db';
|
||||
import { MailerSecurity } from '../env';
|
||||
|
||||
export enum Env {
|
||||
Dev = 'dev',
|
||||
@@ -74,7 +75,7 @@ export interface MailerConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
security: MailerSecurity;
|
||||
authUser: string;
|
||||
authPassword: string;
|
||||
noReplyName: string;
|
||||
@@ -143,6 +144,7 @@ export interface Config {
|
||||
tempDir: string;
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
adminBaseUrl: string;
|
||||
userContentBaseUrl: string;
|
||||
joplinAppBaseUrl: string;
|
||||
signupEnabled: boolean;
|
||||
|
||||
@@ -14,6 +14,14 @@ export function setQueryParameters(url: string, query: any): string {
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export function stripOffQueryParameters(url: string): string {
|
||||
const s = url.split('?');
|
||||
if (s.length <= 1) return url;
|
||||
|
||||
s.pop();
|
||||
return s.join('?');
|
||||
}
|
||||
|
||||
export function resetPasswordUrl(token: string): string {
|
||||
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
|
||||
}
|
||||
@@ -42,14 +50,38 @@ export function homeUrl(): string {
|
||||
return `${config().baseUrl}/home`;
|
||||
}
|
||||
|
||||
export function itemsUrl(): string {
|
||||
return `${config().baseUrl}/items`;
|
||||
}
|
||||
|
||||
export function changesUrl(): string {
|
||||
return `${config().baseUrl}/changes`;
|
||||
}
|
||||
|
||||
export function loginUrl(): string {
|
||||
return `${config().baseUrl}/login`;
|
||||
}
|
||||
|
||||
export function userDeletionsUrl(): string {
|
||||
return `${config().baseUrl}/user_deletions`;
|
||||
export function adminUserDeletionsUrl(): string {
|
||||
return `${config().adminBaseUrl}/user_deletions`;
|
||||
}
|
||||
|
||||
export function userUrl(userId: Uuid): string {
|
||||
return `${config().baseUrl}/users/${userId}`;
|
||||
}
|
||||
|
||||
export function adminDashboardUrl(): string {
|
||||
return `${config().adminBaseUrl}/dashboard`;
|
||||
}
|
||||
|
||||
export function adminUsersUrl() {
|
||||
return `${config().adminBaseUrl}/users`;
|
||||
}
|
||||
|
||||
export function adminUserUrl(userId: string) {
|
||||
return `${config().adminBaseUrl}/users/${userId}`;
|
||||
}
|
||||
|
||||
export function adminTasksUrl() {
|
||||
return `${config().adminBaseUrl}/tasks`;
|
||||
}
|
||||
|
||||
4
packages/server/src/views/admin/dashboard.mustache
Normal file
4
packages/server/src/views/admin/dashboard.mustache
Normal file
@@ -0,0 +1,4 @@
|
||||
<h2 class="title">{{global.appName}} admin dashboard</h2>
|
||||
<div class="block readable-block">
|
||||
<p class="block">This is the admin dashboard. Please select an option on the left.</p>
|
||||
</div>
|
||||
170
packages/server/src/views/admin/user.mustache
Normal file
170
packages/server/src/views/admin/user.mustache
Normal file
@@ -0,0 +1,170 @@
|
||||
<h1 class="title">{{pageTitle}}</h1>
|
||||
|
||||
<form id="user_form" action="{{{postUrl}}}" method="POST" class="block">
|
||||
|
||||
<div class="block">
|
||||
{{> errorBanner}}
|
||||
{{{csrfTag}}}
|
||||
<input type="hidden" name="id" value="{{user.id}}"/>
|
||||
<input type="hidden" name="is_new" value="{{isNew}}"/>
|
||||
<div class="field">
|
||||
<label class="label">Full name</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="full_name" value="{{user.full_name}}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Email</label>
|
||||
<div class="control">
|
||||
<input class="input" type="email" name="email" value="{{user.email}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#showAccountTypes}}
|
||||
<div class="field">
|
||||
<label class="label">Account type</label>
|
||||
<div class="select">
|
||||
<select name="account_type">
|
||||
{{#accountTypes}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/accountTypes}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
|
||||
</div>
|
||||
{{/showAccountTypes}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max item size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max total size</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can share notebook</label>
|
||||
<div class="select">
|
||||
<select name="can_share_folder">
|
||||
{{#canShareFolderOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canShareFolderOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Can upload</label>
|
||||
<div class="select">
|
||||
<select name="can_upload">
|
||||
{{#canUploadOptions}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/canUploadOptions}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<div class="control">
|
||||
<input id="password" class="input" type="password" name="password" autocomplete="new-password"/>
|
||||
</div>
|
||||
<p id="password_strength" class="help"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Repeat password</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
||||
</div>
|
||||
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
|
||||
</div>
|
||||
|
||||
<div class="control block">
|
||||
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
|
||||
{{#showImpersonateButton}}
|
||||
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
|
||||
{{/showImpersonateButton}}
|
||||
{{#showResetPasswordButton}}
|
||||
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
|
||||
{{/showResetPasswordButton}}
|
||||
{{#showDisableButton}}
|
||||
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
|
||||
{{/showDisableButton}}
|
||||
{{#showRestoreButton}}
|
||||
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
|
||||
{{/showRestoreButton}}
|
||||
{{#showScheduleDeletionButton}}
|
||||
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
|
||||
{{/showScheduleDeletionButton}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#subscription}}
|
||||
<h1 class="title">Your subscription</h1>
|
||||
|
||||
<div class="block">
|
||||
<div class="control block">
|
||||
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
|
||||
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
|
||||
{{#showUpdateSubscriptionBasic}}
|
||||
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
|
||||
{{/showUpdateSubscriptionBasic}}
|
||||
{{#showUpdateSubscriptionPro}}
|
||||
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
|
||||
{{/showUpdateSubscriptionPro}}
|
||||
</div>
|
||||
</div>
|
||||
{{/subscription}}
|
||||
|
||||
</form>
|
||||
|
||||
{{#hasFlags}}
|
||||
<div class="content user-flags block">
|
||||
<h1 class="title">Flags</h1>
|
||||
<form action="{{{postUrl}}}" method="POST">
|
||||
{{{csrfTag}}}
|
||||
{{#userFlagViews}}
|
||||
<ul>
|
||||
<li><label class="checkbox"><input type="checkbox" name="user_flag_{{type}}"> {{message}}</label></li>
|
||||
</ul>
|
||||
{{/userFlagViews}}
|
||||
<input type="submit" name="delete_user_flags" class="button is-warning" value="Delete selected flags" />
|
||||
<p class="help">Note: normally it should not be needed to manually delete a flag because that's automatically handled by the system. So if it's necessary it means there's a bug that should be fixed.</p>
|
||||
</form>
|
||||
</div>
|
||||
{{/hasFlags}}
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
document.getElementById("user_form").addEventListener('submit', function(event) {
|
||||
if (event.submitter.getAttribute('name') === 'disable_button') {
|
||||
const ok = confirm('Disable this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'restore_button') {
|
||||
const ok = confirm('Restore this account?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
|
||||
const ok = confirm('Downgrade to Basic subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
|
||||
const ok = confirm('Upgrade to Pro subscription?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
setupPasswordStrengthHandler();
|
||||
});
|
||||
</script>
|
||||
60
packages/server/src/views/admin/users.mustache
Normal file
60
packages/server/src/views/admin/users.mustache
Normal file
@@ -0,0 +1,60 @@
|
||||
<div class="block">
|
||||
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
|
||||
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Full name</th>
|
||||
<th>Email</th>
|
||||
<th>Account</th>
|
||||
<th>Max Item Size</th>
|
||||
<th>Total Size</th>
|
||||
<th>Max Total Size</th>
|
||||
<th>Can share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#users}}
|
||||
<tr class="{{rowClassName}}">
|
||||
<td><a href="{{{url}}}">{{displayName}}</a></td>
|
||||
<td>{{email}}</td>
|
||||
<td>{{formattedAccountType}}</td>
|
||||
<td>{{formattedItemMaxSize}}</td>
|
||||
<td class="{{totalSizeClass}}">{{formattedTotalSize}} ({{formattedTotalSizePercent}})</td>
|
||||
<td>{{formattedMaxTotalSize}}</td>
|
||||
<td>{{formattedCanShareFolder}}</td>
|
||||
</tr>
|
||||
{{/users}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="block">
|
||||
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
|
||||
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(() => {
|
||||
function toggleDisabled() {
|
||||
if ($('.hide-disabled').length) {
|
||||
$('.hide-disabled').addClass('show-disabled');
|
||||
$('.hide-disabled').removeClass('hide-disabled');
|
||||
$('.show-disabled').text('Show disabled');
|
||||
$('table tr.is-disabled').hide();
|
||||
} else {
|
||||
$('.show-disabled').addClass('hide-disabled');
|
||||
$('.show-disabled').removeClass('show-disabled');
|
||||
$('.hide-disabled').text('Hide disabled');
|
||||
$('table tr.is-disabled').show();
|
||||
}
|
||||
}
|
||||
|
||||
toggleDisabled();
|
||||
|
||||
$('.toggle-disabled-button').click(() => {
|
||||
toggleDisabled();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user