1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-08-08 06:55:26 +02:00

Ft: users db, multi user auth, files and pages authored by username

This commit is contained in:
binizikrr 2022-09-15 19:54:59 +03:00
parent c383ee48c2
commit c2c891740a
26 changed files with 29263 additions and 4255 deletions

View file

@ -1,5 +1,5 @@
# codex-docs password.
PASSWORD=
# codex-docs admin password.
ADMIN_PASSWORD=
# Backend errors tracking Integraton Token
HAWK_TOKEN_BACKEND=

23310
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,9 +34,10 @@ class Transport {
* @returns {Promise<FileData>}
*/
public static async save(multerData: Dict, map: Dict): Promise<FileData> {
const { originalname: name, path, filename, size, mimetype, url } = multerData;
const { username, originalname: name, path, filename, size, mimetype, url } = multerData;
const file = new File({
username,
name,
filename,
path,
@ -60,10 +61,11 @@ class Transport {
* Fetches file by passed URL
*
* @param {string} url - URL of the file
* @param {string} username username
* @param {object} map - object that represents how should fields of File object should be mapped to response
* @returns {Promise<FileData>}
*/
public static async fetch(url: string, map: Dict): Promise<FileData> {
public static async fetch(url: string, username: string, map: Dict): Promise<FileData> {
const fetchedFile = await fetch(url);
const buffer = await fetchedFile.buffer();
const filename = await random16();
@ -85,6 +87,7 @@ class Transport {
const mimeType = type ? type.mime : fetchedMimeType;
const file = new File({
username,
name: url,
filename: `${filename}.${ext}`,
path: `${config.get('uploads')}/${filename}.${ext}`,

View file

@ -6,6 +6,7 @@ const filesDb = database['files'];
* @typedef {object} FileData
*
* @property {string} _id - file id
* @property {string} username - username
* @property {string} name - original file name
* @property {string} filename - name of uploaded file
* @property {string} path - path to uploaded file
@ -15,6 +16,7 @@ const filesDb = database['files'];
*/
export interface FileData {
_id?: string;
username?: string;
name?: string;
filename?: string;
path?: string;
@ -29,6 +31,7 @@ export interface FileData {
* @class File model
*
* @property {string} _id - file id
* @property {string} username - username
* @property {string} name - original file name
* @property {string} filename - name of uploaded file
* @property {string} path - path to uploaded file
@ -37,6 +40,7 @@ export interface FileData {
*/
class File {
public _id?: string;
public username?: string;
public name?: string;
public filename?: string;
public path?: string;
@ -102,8 +106,9 @@ class File {
* @param {FileData} fileData - info about file
*/
public set data(fileData: FileData) {
const { name, filename, path, mimetype, size, url } = fileData;
const { username, name, filename, path, mimetype, size, url } = fileData;
this.username = username || this.username;
this.name = name || this.name;
this.filename = filename || this.filename;
this.path = path ? this.processPath(path) : this.path;
@ -120,6 +125,7 @@ class File {
public get data(): FileData {
return {
_id: this._id,
username: this.username,
name: this.name,
filename: this.filename,
path: this.path,

View file

@ -6,6 +6,7 @@ const pagesDb = database['pages'];
/**
* @typedef {object} PageData
* @property {string} _id - page id
* @property {string} username - username
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
@ -13,6 +14,7 @@ const pagesDb = database['pages'];
*/
export interface PageData {
_id?: string;
username?: string;
title?: string;
uri?: string;
body?: any;
@ -24,6 +26,7 @@ export interface PageData {
* @class Page model
*
* @property {string} _id - page id
* @property {string} username - username
* @property {string} title - page title
* @property {string} uri - page uri
* @property {*} body - page body
@ -31,6 +34,7 @@ export interface PageData {
*/
class Page {
public _id?: string;
public username?: string;
public body?: any;
public title?: string;
public uri?: string;
@ -95,8 +99,9 @@ class Page {
* @param {PageData} pageData - page's data
*/
public set data(pageData: PageData) {
const { body, parent, uri } = pageData;
const { username, body, parent, uri } = pageData;
this.username = username || this.username;
this.body = body || this.body;
this.title = this.extractTitleFromBody();
this.uri = uri || '';
@ -111,6 +116,7 @@ class Page {
public get data(): PageData {
return {
_id: this._id,
username: this.username,
title: this.title,
uri: this.uri,
body: this.body,

172
src/backend/models/user.ts Normal file
View file

@ -0,0 +1,172 @@
import database from '../utils/database/index.js';
import config from 'config';
import { binaryMD5 } from '../utils/crypto.js';
const usersDb = database['users'];
/**
* @typedef {object} UserData
*
* @property {string} _id - user id
* @property {string} username - username
* @property {string} role - user access role
* @property {string} password - user plain text password
* @property {string} passwordHash - user password hash
*/
export interface UserData {
_id?: string;
username?: string;
role?: string;
password?: string,
passwordHash?: string;
[key: string]: string | number | undefined;
}
/**
* @class User
* @class User model
*
* @property {string} _id - user id
* @property {string} username - username
* @property {string} role - user access role
* @property {string} passwordHash - user password hash
*/
class User {
public _id?: string;
public username?: string;
public role?: string;
public passwordHash?: string;
/**
* @class
*
* @param {UserData} data - info about user
*/
constructor(data: UserData = {}) {
if (data === null) {
data = {};
}
if (data._id) {
this._id = data._id;
}
if (data.password) {
data.passwordHash = binaryMD5(data.password + config.get('secret'));
delete data.password;
}
this.data = data;
}
/**
* Find and return model of file with given id
*
* @param {string} _id - user id
* @returns {Promise<User>}
*/
public static async get(_id: string): Promise<User> {
const data: UserData = await usersDb.findOne({ _id });
return new User(data);
}
/**
* Find and return model of user with given id
*
* @param {string} username - username
* @returns {Promise<User>}
*/
public static async getByUsername(username: string): Promise<User> {
const data = await usersDb.findOne({ username });
return new User(data);
}
/**
* Find all users which match passed query object
*
* @param {object} query - input query
* @returns {Promise<User[]>}
*/
public static async getAll(query: Record<string, unknown> = {}): Promise<User[]> {
const records = await usersDb.find(query);
return Promise.all(records.map(data => new User(data)));
}
/**
* Set UserData object fields to internal model fields
*
* @param {UserData} userData - info about user
*/
public set data(userData: UserData) {
const { username, role, passwordHash } = userData;
this.username = username || this.username;
this.role = role || this.role;
this.passwordHash = passwordHash || this.passwordHash;
}
/**
* Return UserData object
*
* @returns {UserData}
*/
public get data(): UserData {
return {
_id: this._id,
username: this.username,
role: this.role,
passwordHash: this.passwordHash,
};
}
/**
* Save or update user data in the database
*
* @returns {Promise<User>}
*/
public async save(): Promise<User> {
if (!this._id) {
if (this.username) {
const user = await User.getByUsername(this.username);
if (typeof user._id !== 'undefined') {
throw new Error('Username is occupied by another user');
}
}
const insertedRow = await usersDb.insert(this.data) as { _id: string };
this._id = insertedRow._id;
} else {
await usersDb.update({ _id: this._id }, this.data);
}
return this;
}
/**
* Remove user data from the database
*
* @returns {Promise<User>}
*/
public async destroy(): Promise<User> {
await usersDb.remove({ _id: this._id });
delete this._id;
return this;
}
/**
* Return readable user data
*
* @returns {UserData}
*/
public toJSON(): UserData {
return this.data;
}
}
export default User;

View file

@ -0,0 +1,21 @@
import express, { NextFunction, Request, Response } from 'express';
import { allowAdmin } from './middlewares/locals.js';
import verifyToken from './middlewares/token.js';
const router = express.Router();
/**
* Admin settings
*/
router.get('/admin/settings', verifyToken, allowAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
res.render('pages/settings', {
users: [],
});
} catch (error) {
res.status(404);
next(error);
}
});
export default router;

View file

@ -2,6 +2,7 @@ import express, { Request, Response } from 'express';
import multerFunc from 'multer';
import Pages from '../../controllers/pages.js';
import PagesOrder from '../../controllers/pagesOrder.js';
import verifyToken from '../middlewares/token.js';
const router = express.Router();
const multer = multerFunc();
@ -54,10 +55,11 @@ router.get('/pages', async (req: Request, res: Response) => {
*
* Create new page in the database
*/
router.put('/page', multer.none(), async (req: Request, res: Response) => {
router.put('/page', multer.none(), verifyToken, async (req: Request, res: Response) => {
try {
const { title, body, parent } = req.body;
const page = await Pages.insert({
username: res.locals.tokenData.username,
title,
body,
parent,
@ -87,7 +89,7 @@ router.put('/page', multer.none(), async (req: Request, res: Response) => {
*
* Update page data in the database
*/
router.post('/page/:id', multer.none(), async (req: Request, res: Response) => {
router.post('/page/:id', multer.none(), verifyToken, async (req: Request, res: Response) => {
const { id } = req.params;
try {
@ -122,6 +124,7 @@ router.post('/page/:id', multer.none(), async (req: Request, res: Response) => {
}
page = await Pages.update(id, {
username: res.locals.tokenData.username,
title,
body,
parent,
@ -144,7 +147,7 @@ router.post('/page/:id', multer.none(), async (req: Request, res: Response) => {
*
* Remove page from the database
*/
router.delete('/page/:id', async (req: Request, res: Response) => {
router.delete('/page/:id', verifyToken, async (req: Request, res: Response) => {
try {
const pageId = req.params.id;
const page = await Pages.get(pageId);

View file

@ -5,6 +5,7 @@ import mkdirp from 'mkdirp';
import config from 'config';
import Transport from '../../controllers/transport.js';
import { random16 } from '../../utils/crypto.js';
import verifyToken from '../middlewares/token.js';
const router = Router();
@ -59,7 +60,7 @@ const fileUploader = multer({
/**
* Accepts images to upload
*/
router.post('/transport/image', imageUploader, async (req: Request, res: Response) => {
router.post('/transport/image', verifyToken, imageUploader, async (req: Request, res: Response) => {
const response = {
success: 0,
message: '',
@ -79,6 +80,7 @@ router.post('/transport/image', imageUploader, async (req: Request, res: Respons
const fileData = {
...req.files.image[0],
username: res.locals.tokenData.username,
url: '/uploads/' + req.files.image[0].filename,
};
@ -100,7 +102,7 @@ router.post('/transport/image', imageUploader, async (req: Request, res: Respons
/**
* Accepts files to upload
*/
router.post('/transport/file', fileUploader, async (req: Request, res: Response) => {
router.post('/transport/file', verifyToken, fileUploader, async (req: Request, res: Response) => {
const response = { success: 0 };
if (req.files === undefined) {
@ -117,7 +119,13 @@ router.post('/transport/file', fileUploader, async (req: Request, res: Response)
try {
Object.assign(
response,
await Transport.save(req.files.file[0], req.body.map ? JSON.parse(req.body.map) : undefined)
await Transport.save(
{
...req.files.file[0],
username: res.locals.tokenData.username,
},
req.body.map ? JSON.parse(req.body.map) : undefined
)
);
response.success = 1;
@ -130,7 +138,7 @@ router.post('/transport/file', fileUploader, async (req: Request, res: Response)
/**
* Accept file url to fetch
*/
router.post('/transport/fetch', multer().none(), async (req: Request, res: Response) => {
router.post('/transport/fetch', verifyToken, multer().none(), async (req: Request, res: Response) => {
const response = { success: 0 };
if (!req.body.url) {
@ -140,7 +148,7 @@ router.post('/transport/fetch', multer().none(), async (req: Request, res: Respo
}
try {
Object.assign(response, await Transport.fetch(req.body.url, req.body.map ? JSON.parse(req.body.map) : undefined));
Object.assign(response, await Transport.fetch(req.body.url, res.locals.tokenData.username, req.body.map ? JSON.parse(req.body.map) : undefined));
response.success = 1;
res.status(200).json(response);

View file

@ -2,6 +2,8 @@ import express, { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import config from 'config';
import csrf from 'csurf';
import User from '../models/user.js';
import { binaryMD5 } from '../utils/crypto.js';
const router = express.Router();
const csrfProtection = csrf({ cookie: true });
@ -14,6 +16,7 @@ router.get('/auth', csrfProtection, function (req: Request, res: Response) {
res.render('auth', {
title: 'Login page',
csrfToken: req.csrfToken(),
redirectUrl: req.query.redirect || '/',
});
});
@ -21,22 +24,65 @@ router.get('/auth', csrfProtection, function (req: Request, res: Response) {
* Process given password
*/
router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Response) => {
const { ADMIN_PASSWORD } = process.env;
const { _redirect: redirectUrl } = req.body;
try {
if (!process.env.PASSWORD) {
if (!ADMIN_PASSWORD) {
res.render('auth', {
title: 'Login page',
header: 'Password not set',
header: 'Admin password not set',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;
}
if (req.body.password !== process.env.PASSWORD) {
const { username, password } = req.body;
if (!username) {
res.render('auth', {
title: 'Login page',
header: 'Username required',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;
}
if (username === 'admin' && password !== ADMIN_PASSWORD) {
res.render('auth', {
title: 'Login page',
header: 'Wrong password',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;
}
const passwordHash = binaryMD5(password + config.get('secret'));
const user = await User.getByUsername(username);
if (username !== 'admin' && typeof user.username === 'undefined') {
res.render('auth', {
title: 'Login page',
header: 'Wrong username',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;
}
if (username !== 'admin' && user.passwordHash !== passwordHash) {
res.render('auth', {
title: 'Login page',
header: 'Wrong password',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;
@ -45,20 +91,23 @@ router.post('/auth', parseForm, csrfProtection, async (req: Request, res: Respon
const token = jwt.sign({
iss: 'Codex Team',
sub: 'auth',
username,
role: user.role || 'admin',
iat: Date.now(),
}, process.env.PASSWORD + config.get('secret'));
}, config.get('secret'));
res.cookie('authToken', token, {
httpOnly: true,
expires: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
});
res.redirect('/');
res.redirect(redirectUrl || '/');
} catch (err) {
res.render('auth', {
title: 'Login page',
header: 'Password not set',
header: 'Unexpected error',
csrfToken: req.csrfToken(),
redirectUrl,
});
return;

View file

@ -2,6 +2,7 @@ import express from 'express';
import home from './home.js';
import pages from './pages.js';
import auth from './auth.js';
import admin from './admin.js';
import aliases from './aliases.js';
import api from './api/index.js';
import pagesMiddleware from './middlewares/pages.js';
@ -10,6 +11,7 @@ const router = express.Router();
router.use('/', pagesMiddleware, home);
router.use('/', pagesMiddleware, pages);
router.use('/', pagesMiddleware, admin);
router.use('/', pagesMiddleware, auth);
router.use('/api', api);
router.use('/', aliases);

View file

@ -7,10 +7,25 @@ import { NextFunction, Request, Response } from 'express';
* @param res - response object
* @param next - next function
*/
export default function allowEdit(req: Request, res: Response, next: NextFunction): void {
export function allowEdit(req: Request, res: Response, next: NextFunction): void {
if (res.locals.isAuthorized) {
next();
} else {
res.redirect('/auth');
res.redirect('/auth?redirect=' + req.path);
}
}
/**
* Middleware for checking locals.isAuthorized property and checking that username is equal admin, which allows to edit/create users
*
* @param req - request object
* @param res - response object
* @param next - next function
*/
export function allowAdmin(req: Request, res: Response, next: NextFunction): void {
if (res.locals.isAuthorized && res.locals.tokenData.username === 'admin') {
next();
} else {
res.redirect('/auth?redirect=' + req.path);
}
}

View file

@ -71,7 +71,8 @@ export default asyncMiddleware(async (req: Request, res: Response, next: NextFun
const pages = await Pages.getAll();
const pagesOrder = await PagesOrder.getAll();
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 2);
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 7);
console.log(JSON.stringify(res.locals.menu));
} catch (error) {
console.log('Can not load menu:', error);
}

View file

@ -14,16 +14,17 @@ export default async function verifyToken(req: Request, res: Response, next: Nex
const token = req.cookies.authToken;
try {
if (!process.env.PASSWORD) {
if (!process.env.ADMIN_PASSWORD) {
res.locals.isAuthorized = false;
next();
return;
}
const decodedToken = jwt.verify(token, process.env.PASSWORD + config.get('secret'));
const decodedToken = jwt.verify(token, config.get('secret'));
res.locals.isAuthorized = !!decodedToken;
res.locals.tokenData = decodedToken;
next();
} catch (err) {

View file

@ -2,7 +2,7 @@ import express, { NextFunction, Request, Response } from 'express';
import Pages from '../controllers/pages.js';
import PagesOrder from '../controllers/pagesOrder.js';
import verifyToken from './middlewares/token.js';
import allowEdit from './middlewares/locals.js';
import { allowEdit } from './middlewares/locals.js';
import PagesFlatArray from '../models/pagesFlatArray.js';
const router = express.Router();
@ -12,10 +12,7 @@ const router = express.Router();
*/
router.get('/page/new', verifyToken, allowEdit, async (req: Request, res: Response, next: NextFunction) => {
try {
const pagesAvailableGrouped = await Pages.groupByParent();
res.render('pages/form', {
pagesAvailableGrouped,
page: null,
});
} catch (error) {

View file

@ -3,6 +3,7 @@ import { AliasData } from '../../models/alias.js';
import { FileData } from '../../models/file.js';
import { PageData } from '../../models/page.js';
import { PageOrderData } from '../../models/pageOrder.js';
import { UserData } from '../../models/user.js';
import initDb from './initDb.js';
/**
@ -172,4 +173,5 @@ export default {
aliases: new Database<AliasData>(initDb('aliases')),
pagesOrder: new Database<PageOrderData>(initDb('pagesOrder')),
files: new Database<FileData>(initDb('files')),
users: new Database<UserData>(initDb('users')),
};

View file

@ -6,12 +6,14 @@
┬┴┬┴┤ ͜ʖ ͡°) ├┬┴┬┴
</h1>
<p>
Enter a password to access pages editing
Please login to access pages editing
</p>
<p>
{{ header }}
</p>
<input type="hidden" name="_csrf" value={{ csrfToken }}>
<input type="hidden" name="_redirect" value={{ redirectUrl }}>
<input type="username" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Login">
</form>

View file

@ -10,6 +10,11 @@
<li class="docs-header__menu-add docs-header__menu-add--mobile">
{% include 'components/button.twig' with {icon: 'plus', size: 'small', url: '/page/new'} %}
</li>
{% if tokenData.username == "admin" %}
<li class="docs-header__menu-settings">
{% include 'components/button.twig' with {icon: 'settings', size: 'small', url: '/admin/settings'} %}
</li>
{% endif %}
{% endif %}
{% for option in config.menu %}
<li>

View file

@ -34,6 +34,81 @@
</div>
</a>
</li>
{% if child.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in child.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and page._id == child._id ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>&nbsp;&bullet; {{ child.title | striptags }}</span>
</div>
</a>
</li>
{% if child.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in child.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and page._id == child._id ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>&nbsp;&nbsp;&nbsp;&nbsp&bullet; {{ child.title | striptags }}</span>
</div>
</a>
</li>
{% if child.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in child.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and page._id == child._id ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp&bullet; {{ child.title | striptags }}</span>
</div>
</a>
</li>
{% if child.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in child.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and page._id == child._id ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp&bullet; {{ child.title | striptags }}</span>
</div>
</a>
</li>
{% if child.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in child.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and page._id == child._id ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp&bullet; {{ child.title | striptags }}</span>
</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}

View file

@ -26,7 +26,7 @@
{% endif %}
<select id="parent" name="parent">
<option value="0">Root</option>
{% for _page in pagesAvailableGrouped %}
{% for _page in menu %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
@ -35,6 +35,126 @@
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% if _page.children.length != "0" %}
{% for _page in _page.children %}
{% if _page._id != currentPageId %}
<option value="{{ _page._id }}" disabled {{ page is not empty and page._parent == _page._id ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
</option>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
</select>

View file

@ -20,6 +20,10 @@
{% endif %}
</div>
<time class="page__header-time">
{% if page.username %}
Author <span style="color: #000;">@{{ page.username }}</span>
&nbsp;&nbsp;|&nbsp;&nbsp;
{% endif %}
Last edit {{ (page.body.time / 1000) | date("M d Y") }}
</time>
{% if isAuthorized == true %}

View file

@ -0,0 +1,9 @@
{% extends 'layout.twig' %}
{% block body %}
<style>
.docs-header__menu-settings {
display: none !important;
}
</style>
{% endblock %}

View file

@ -121,7 +121,7 @@ export default class Sidebar {
const itemsCount = sectionList.children.length;
sectionList.style.maxHeight = `${itemsCount * ITEM_HEIGHT}px`;
// sectionList.style.maxHeight = `${itemsCount * ITEM_HEIGHT}px`;
}
/**

View file

@ -13,6 +13,15 @@
margin: 40px 0 20px;
}
input[type="username"] {
padding: 9px;
font-size: 15px;
outline: none;
border-radius: 3px;
-webkit-appearance: none;
border: 1px solid var(--color-line-gray);
}
input[type="password"] {
padding: 9px;
font-size: 15px;

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 478.7 478.7" style="enable-background:new 0 0 478.7 478.7;" xml:space="preserve">
<g>
<g>
<path d="M383,205.7l-22.5-3.8c-2.4-7.6-5.3-14.8-9.1-21.8l13.3-18.6c5.6-7.9,4.8-18.7-2.1-25.5l-19.9-19.9
c-3.7-3.7-8.7-5.9-14-5.9c-4.1,0-8,1.3-11.4,3.7l-18.6,13.3c-7.2-3.8-14.8-6.9-22.6-9.3l-3.7-22.2c-1.6-9.6-9.8-16.5-19.5-16.5
h-28.2c-9.7,0-17.9,6.9-19.5,16.5l-3.8,22.8c-7.5,2.4-14.8,5.4-21.8,9.1l-18.4-13.3c-3.4-2.4-7.3-3.7-11.5-3.7
c-5.3,0-10.3,2.1-14,5.9l-20,19.9c-6.9,6.9-7.8,17.6-2.1,25.5l13.3,18.7c-3.7,7-6.6,14.3-8.9,21.9l-22.2,3.7
c-9.6,1.6-16.5,9.8-16.5,19.5v28.2c0,9.7,6.9,17.9,16.5,19.5l22.8,3.8c2.4,7.5,5.4,14.8,9.1,21.8l-13.2,18.3
c-5.6,7.9-4.8,18.7,2.1,25.5l19.9,19.9c3.7,3.7,8.7,5.9,14,5.9c4.1,0,8-1.3,11.4-3.7l18.7-13.3c6.8,3.6,13.8,6.4,21.1,8.7
l3.7,22.5c1.6,9.6,9.8,16.5,19.5,16.5h28.3c9.7,0,17.9-6.9,19.5-16.5l3.8-22.5c7.6-2.4,14.8-5.3,21.8-9.1l18.6,13.3
c3.4,2.4,7.3,3.7,11.5,3.7l0,0c5.3,0,10.2-2.1,14-5.9l19.9-19.9c6.9-6.9,7.8-17.6,2.1-25.5l-13.2-18.4c3.7-7,6.8-14.3,9.1-21.8
l22.5-3.7c9.6-1.6,16.5-9.8,16.5-19.5v-28.2C399.5,215.6,392.6,207.4,383,205.7z M381.5,253.4c0,0.9-0.6,1.6-1.5,1.7l-28.1,4.7
c-3.6,0.6-6.3,3.2-7.2,6.6c-2.6,9.8-6.4,19.3-11.6,28c-1.8,3.1-1.6,6.9,0.4,9.8l16.5,23.2c0.5,0.6,0.4,1.6-0.2,2.3l-19.9,19.9
c-0.5,0.5-0.9,0.5-1.3,0.5s-0.7-0.1-1-0.4l-23.2-16.5c-2.8-2.1-6.8-2.2-9.8-0.4c-8.8,5.2-18.2,9.1-28,11.6
c-3.5,0.9-6.1,3.7-6.6,7.2l-4.8,28.1c-0.1,0.9-0.9,1.5-1.7,1.5h-28.3c-0.9,0-1.6-0.6-1.7-1.5l-4.7-28.1c-0.6-3.6-3.2-6.3-6.6-7.2
c-9.6-2.5-18.7-6.3-27.4-11.2c-1.4-0.8-3-1.2-4.6-1.2c-1.8,0-3.7,0.5-5.2,1.6l-23.4,16.9c-0.4,0.2-0.6,0.4-1,0.4
c-0.3,0-0.8-0.1-1.3-0.5l-19.9-19.9c-0.6-0.6-0.6-1.6-0.2-2.3l16.5-23c2.1-2.9,2.2-6.9,0.4-9.9c-5.2-8.7-9.2-18.1-11.8-28
c-0.9-3.4-3.7-6-7.2-6.6l-28.3-4.8c-0.9-0.1-1.5-0.9-1.5-1.7v-28.2c0-0.9,0.6-1.6,1.5-1.7l27.9-4.7c3.6-0.6,6.4-3.2,7.3-6.7
c2.5-9.8,6.3-19.3,11.4-28.1c1.8-3.1,1.6-6.9-0.5-9.8l-16.6-23.4c-0.5-0.6-0.4-1.6,0.2-2.3l19.9-19.9c0.5-0.5,0.9-0.5,1.3-0.5
c0.4,0,0.7,0.1,1,0.4l23,16.5c2.9,2.1,6.9,2.2,9.9,0.4c8.7-5.2,18.1-9.2,28-11.8c3.4-0.9,6-3.7,6.6-7.2l4.8-28.3
c0.1-0.9,0.9-1.5,1.7-1.5H253c0.9,0,1.6,0.6,1.7,1.5l4.7,27.9c0.6,3.6,3.2,6.4,6.7,7.3c10.1,2.6,19.7,6.5,28.7,11.8
c3.1,1.8,6.9,1.6,9.8-0.4l23-16.5c0.4-0.2,0.6-0.4,1-0.4c0.3,0,0.8,0.1,1.3,0.5l19.9,19.9c0.6,0.6,0.6,1.6,0.2,2.3l-16.5,23.2
c-2.1,2.8-2.2,6.8-0.4,9.8c5.2,8.8,9.1,18.2,11.6,28c0.9,3.5,3.7,6.1,7.2,6.6l28.1,4.8c0.9,0.1,1.5,0.9,1.5,1.7L381.5,253.4
L381.5,253.4z"/>
<path d="M239.4,170.3c-38.1,0-69,31-69,69s31,69,69,69s69-31,69-69S277.5,170.3,239.4,170.3z M239.4,290.3c-28.2,0-51-22.9-51-51
s22.9-51,51-51s51,22.9,51,51S267.6,290.3,239.4,290.3z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

9602
yarn.lock

File diff suppressed because it is too large Load diff