Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-08-11 19:03:19 +02:00
commit 09e9b968fe
20 changed files with 828 additions and 898 deletions

View File

@ -1,6 +1,6 @@
{
"name": "ily.li",
"version": "0.4.6",
"version": "0.5.0",
"description": "Self-hosted file pusher",
"repository": "git@gitlab.com:ArisuOngaku/ily.li.git",
"author": "Alice Gaudon <alice@gaudon.pro>",
@ -22,13 +22,13 @@
"@types/formidable": "^1.0.31",
"@types/jest": "^26.0.4",
"@types/mysql": "^2.15.15",
"@types/node": "^13.13.2",
"@types/node": "^14.0.27",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/ws": "^7.2.6",
"babel-loader": "^8.1.0",
"concurrently": "^5.1.0",
"css-loader": "^3.5.2",
"css-loader": "^4.2.1",
"feather-icons": "^4.28.0",
"file-loader": "^6.0.0",
"imagemin": "^7.0.1",
@ -38,7 +38,7 @@
"imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1",
"jest": "^26.1.0",
"mini-css-extract-plugin": "^0.9.0",
"mini-css-extract-plugin": "^0.10.0",
"node-sass": "^4.14.0",
"nodemon": "^2.0.3",
"sass-loader": "^9.0.2",

View File

@ -34,11 +34,12 @@ import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable";
import BackendController from "./controllers/BackendController";
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
import AuthTokenController from "./controllers/AuthTokenController";
import URLRedirectController from "./controllers/URLRedirectController";
import LinkController from "./controllers/LinkController";
import BackendController from "wms-core/helpers/BackendController";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
export default class App extends Application {
private readonly port: number;
@ -75,13 +76,18 @@ export default class App extends Application {
const expressAppComponent = new ExpressAppComponent(this.port);
this.use(expressAppComponent);
this.use(new NunjucksComponent());
// Base
this.use(new LogRequestsComponent());
// Static files
this.use(new ServeStaticDirectoryComponent('public'));
this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons'));
// Dynamic views and routes
this.use(new NunjucksComponent());
this.use(new RedirectBackComponent());
// Maintenance
this.use(new MaintenanceComponent(this, () => {
return redisComponent.canServe() && mysqlComponent.canServe();
@ -95,6 +101,8 @@ export default class App extends Application {
// Session
this.use(redisComponent);
this.use(new SessionComponent(redisComponent));
// Auth
this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> {
public async getProofForSession(session: Express.Session): Promise<any | null> {
return await MagicLink.bySessionID(session.id, [MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER]);

View File

@ -1,6 +0,0 @@
import {MailTemplate} from "wms-core/Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link'
);

View File

@ -1,12 +0,0 @@
import {MailTemplate} from "wms-core/Mail";
import config from "config";
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'account_review_notice',
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`
);
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'pending_account_review',
() => 'A new account is pending review on ' + config.get<string>('domain'),
);

10
src/Utils.ts Normal file
View File

@ -0,0 +1,10 @@
export function encodeRFC5987ValueChars(str: string) {
return encodeURIComponent(str).
// Note that although RFC3986 reserves "!", RFC5987 does not,
// so we do not need to escape it
replace(/['()*]/g, c => "%" + c.charCodeAt(0).toString(16)). // i.e., %27 %28 %29 %2a (Note that valid encoding of "*" is %2A
// which necessitates calling toUpperCase() to properly encode)
// The following are not required for percent-encoding per RFC5987,
// so we can allow for a little better readability over the wire: |`^
replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(parseInt(hex, 16)));
}

View File

@ -1,5 +1,5 @@
import MagicLinkAuthController from "wms-core/auth/magic_link/MagicLinkAuthController";
import {MAGIC_LINK_MAIL} from "../MagicLinkMail";
import {MAGIC_LINK_MAIL} from "wms-core/Mails";
export default class AuthController extends MagicLinkAuthController {
public constructor() {

View File

@ -1,69 +0,0 @@
import Controller from "wms-core/Controller";
import {REQUIRE_ADMIN_MIDDLEWARE, REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import User from "wms-core/auth/models/User";
import {NotFoundHttpError} from "wms-core/HttpError";
import {ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE} from "../Mails";
import Mail from "wms-core/Mail";
import config from "config";
export default class BackendController extends Controller {
getRoutesPrefix(): string {
return '/backend';
}
routes(): void {
this.get('/', this.getIndex, 'backend', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
if (User.isApprovalMode()) {
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/approve/:id', this.getApproveAccount, 'approve-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/reject/:id', this.getRejectAccount, 'reject-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
}
}
public async getIndex(req: Request, res: Response): Promise<void> {
res.render('backend/index', {
approval_mode: User.isApprovalMode(),
accounts_to_approve: User.isApprovalMode() ? await User.select().count() : 0,
});
}
public async getAccountApproval(req: Request, res: Response): Promise<void> {
const accounts = await User.select().where('approved', 0).with('mainEmail').get();
res.render('backend/accounts_approval', {
accounts: User.isApprovalMode() ? accounts : 0,
});
}
public async getApproveAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
account.approved = true;
await account.save();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true,
link: config.get<string>('base_url') + Controller.route('auth'),
}).send(email!.email!);
req.flash('success', `Account successfully approved.`);
res.redirectBack(Controller.route('accounts-approval'));
}
public async getRejectAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
await account.delete();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false,
}).send(email!.email!);
req.flash('success', `Account successfully deleted.`);
res.redirectBack(Controller.route('accounts-approval'));
}
}

View File

@ -60,7 +60,7 @@ export default class FileController extends Controller {
if (req.body.ttl !== undefined) ttl = parseInt(req.body.ttl);
else if (req.body.expire_after_days !== undefined) ttl = parseInt(req.body.expire_after_days) * 24 * 3600;
const file = new FileModel({
const file = FileModel.create({
user_id: req.models.user!.id,
slug: slug,
real_name: upload.name,
@ -71,7 +71,7 @@ export default class FileController extends Controller {
});
await file.save();
fs.renameSync(upload.path, file.storage_path);
fs.renameSync(upload.path, file.storage_path!);
const domain = req.body.url_domain || config.get<string[]>('allowed_url_domains')[config.get<number>('default_url_domain_for_files')];
res.format({
@ -116,7 +116,7 @@ export default class FileController extends Controller {
}
public static async deleteFile(file: FileModel): Promise<void> {
fs.unlinkSync(file.storage_path);
fs.unlinkSync(file.storage_path!);
await file.delete();
Logger.info('Deleted', file.storage_path, `(${file.real_name})`);
}

View File

@ -1,6 +1,6 @@
import Controller from "wms-core/Controller";
import {NextFunction, Request, Response} from "express";
import {BadRequestError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError, ServerError} from "wms-core/HttpError";
import config from "config";
import {REQUIRE_REQUEST_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import URLRedirect from "../models/URLRedirect";
@ -8,12 +8,14 @@ import URLRedirectController from "./URLRedirectController";
import FileModel from "../models/FileModel";
import generateSlug from "../SlugGenerator";
import FileController, {FILE_UPLOAD_FORM_MIDDLEWARE} from "./FileController";
import * as fs from "fs";
import {encodeRFC5987ValueChars} from "../Utils";
export default class LinkController extends Controller {
routes(): void {
this.post('/', this.postFile, 'post-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
this.delete('/:slug', FileController.deleteFileRoute, 'delete-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
this.get('/:slug', this.downloadFile, 'get-file');
this.get('/:slug', this.getFile, 'get-file');
this.put('/:slug', this.putFile, 'put-file', REQUIRE_REQUEST_AUTH_MIDDLEWARE, FILE_UPLOAD_FORM_MIDDLEWARE);
this.post('/', URLRedirectController.addURL, 'post-url', REQUIRE_REQUEST_AUTH_MIDDLEWARE);
@ -24,7 +26,7 @@ export default class LinkController extends Controller {
this.get(/(.*)/, this.domainFilter);
}
protected async downloadFile(req: Request, res: Response, next: NextFunction): Promise<void> {
protected async getFile(req: Request, res: Response, next: NextFunction): Promise<void> {
console.log('get file', req.params.slug)
const file = await FileModel.getBySlug(req.params.slug);
if (!file) return next();
@ -33,9 +35,20 @@ export default class LinkController extends Controller {
return next();
}
// File type
const fileName = file.real_name!;
const parts = fileName.split('.');
res.type(parts[parts.length - 1]);
// File name
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeRFC5987ValueChars(fileName)}`);
switch (file.storage_type) {
case 'local':
res.download(file.storage_path, file.real_name);
fs.readFile(file.storage_path!, (err, data) => {
if (err) return next(err);
res.send(data);
});
break;
default:
throw new ServerError(`This file cannot be served. Download protocol for ${file.storage_type} storage type not implemented.`);
@ -60,7 +73,7 @@ export default class LinkController extends Controller {
const url = await URLRedirect.getBySlug(req.params.slug);
if (!url) return next();
res.redirect(url.target_url, 301);
res.redirect(url.target_url!, 301);
}
protected async deleteURL(req: Request, res: Response, next: NextFunction): Promise<void> {

View File

@ -16,11 +16,12 @@ export default class MagicLinkController extends _MagicLinkController {
case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER:
await AuthController.checkAndAuth(req, res, magicLink);
const proof = await req.authGuard.isAuthenticated(req.session!);
const user = await proof?.getResource();
if (!res.headersSent) {
if (!res.headersSent && user) {
// Auth success
const user = await req.authGuard.getUserForSession(req.session!);
req.flash('success', `Authentication success. Welcome, ${user?.name}!`);
req.flash('success', `Authentication success. Welcome, ${user.name}!`);
res.redirect(req.query.redirect_uri?.toString() || Controller.route('home'));
}
break;

View File

@ -1,21 +1,28 @@
import {Connection} from "mysql";
import Migration from "wms-core/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory";
import AuthToken from "../models/AuthToken";
export default class CreateAuthTokensTable extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE auth_tokens(' +
'id INT NOT NULL AUTO_INCREMENT,' +
'user_id INT NOT NULL,' +
'secret VARCHAR(64) UNIQUE NOT NULL,' +
'created_at DATETIME NOT NULL DEFAULT NOW(),' +
'used_at DATETIME NOT NULL DEFAULT NOW(),' +
'ttl INT UNSIGNED NOT NULL,' +
'PRIMARY KEY (id)' +
')', connection);
await this.query(`CREATE TABLE auth_tokens
(
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
secret VARCHAR(64) UNIQUE NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
used_at DATETIME NOT NULL DEFAULT NOW(),
ttl INT UNSIGNED NOT NULL,
PRIMARY KEY (id)
)`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS auth_tokens', connection);
await this.query(`DROP TABLE IF EXISTS auth_tokens`, connection);
}
public registerModels(): void {
ModelFactory.register(AuthToken);
}
}

View File

@ -1,24 +1,30 @@
import {Connection} from "mysql";
import Migration from "wms-core/db/Migration";
import ModelFactory from "wms-core/db/ModelFactory";
import FileModel from "../models/FileModel";
export default class CreateFilesTable extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE files(' +
'id INT NOT NULL AUTO_INCREMENT,' +
'user_id INT NOT NULL,' +
'slug VARCHAR(259) UNIQUE NOT NULL,' +
'real_name VARCHAR(259) NOT NULL,' +
'storage_type VARCHAR(64) NOT NULL,' +
'storage_path VARCHAR(1745) NOT NULL,' +
'size INT UNSIGNED NOT NULL,' +
'created_at DATETIME NOT NULL DEFAULT NOW(),' +
'ttl INT UNSIGNED NOT NULL,' +
'PRIMARY KEY (id)' +
')', connection);
await this.query(`CREATE TABLE files
(
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
slug VARCHAR(259) UNIQUE NOT NULL,
real_name VARCHAR(259) NOT NULL,
storage_type VARCHAR(64) NOT NULL,
storage_path VARCHAR(1745) NOT NULL,
size INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
ttl INT UNSIGNED NOT NULL,
PRIMARY KEY (id)
)`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS files', connection);
await this.query(`DROP TABLE IF EXISTS files`, connection);
}
public registerModels(): void {
ModelFactory.register(FileModel);
}
}

View File

@ -1,20 +1,26 @@
import Migration from "wms-core/db/Migration";
import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory";
import URLRedirect from "../models/URLRedirect";
export default class CreateUrlRedirectsTable extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('CREATE TABLE url_redirects(' +
'id INT NOT NULL AUTO_INCREMENT,' +
'user_id INT NOT NULL,' +
'slug VARCHAR(259) UNIQUE NOT NULL,' +
'target_url VARCHAR(1745) NOT NULL,' +
'created_at DATETIME NOT NULL DEFAULT NOW(),' +
'PRIMARY KEY (id)' +
')', connection);
await this.query(`CREATE TABLE url_redirects
(
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
slug VARCHAR(259) UNIQUE NOT NULL,
target_url VARCHAR(1745) NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
)`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('DROP TABLE IF EXISTS url_redirects', connection);
await this.query(`DROP TABLE IF EXISTS url_redirects`, connection);
}
public registerModels(): void {
ModelFactory.register(URLRedirect);
}
}

View File

@ -3,10 +3,13 @@ import {Connection} from "mysql";
export default class IncreaseFilesSizeField extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size BIGINT UNSIGNED', connection);
await this.query(`ALTER TABLE files MODIFY size BIGINT UNSIGNED`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE files MODIFY size INT UNSIGNED', connection);
await this.query(`ALTER TABLE files MODIFY size INT UNSIGNED`, connection);
}
public registerModels(): void {
}
}

View File

@ -1,16 +1,15 @@
import Model from "wms-core/db/Model";
import AuthProof from "wms-core/auth/AuthProof";
import UserEmail from "wms-core/auth/models/UserEmail";
import User from "wms-core/auth/models/User";
import Validator from "wms-core/db/Validator";
import {cryptoRandomDictionary} from "wms-core/Utils";
export default class AuthToken extends Model implements AuthProof {
protected readonly user_id!: number;
protected readonly secret!: string;
protected created_at?: Date;
protected used_at?: Date;
protected readonly ttl!: number;
export default class AuthToken extends Model implements AuthProof<User> {
public id?: number = undefined;
protected readonly user_id?: number = undefined;
protected readonly secret?: string = undefined;
protected created_at?: Date = undefined;
protected used_at?: Date = undefined;
protected readonly ttl?: number = undefined;
constructor(props: any) {
super(props);
@ -20,13 +19,10 @@ export default class AuthToken extends Model implements AuthProof {
}
}
protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('secret', new Validator().defined().between(32, 64));
this.addProperty('created_at', new Validator());
this.addProperty('used_at', new Validator());
this.addProperty('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('secret').defined().between(32, 64);
this.setValidation('ttl').defined().min(1).max(5 * 365 * 24 * 3600 /* 5 years */);
}
public use() {
@ -40,16 +36,10 @@ export default class AuthToken extends Model implements AuthProof {
public getExpirationDate(): Date {
if (!this.created_at) return new Date();
return new Date(this.created_at.getTime() + this.ttl * 1000);
return new Date(this.created_at.getTime() + this.ttl! * 1000);
}
public async getEmail(): Promise<string> {
let userEmail = await UserEmail.select().where('user_id', this.user_id).first();
if (!userEmail) throw new Error("Cannot find main user email for user " + this.user_id);
return userEmail.email;
}
public async getUser(): Promise<User | null> {
public async getResource(): Promise<User | null> {
return await User.getById<User>(this.user_id);
}
@ -57,15 +47,11 @@ export default class AuthToken extends Model implements AuthProof {
return true;
}
public async isOwnedBy(userId: number): Promise<boolean> {
return this.user_id === userId;
}
public async isValid(): Promise<boolean> {
return new Date().getTime() < this.getExpirationDate().getTime();
}
public async revoke(session: Express.Session): Promise<void> {
public async revoke(): Promise<void> {
await this.delete();
}
}

View File

@ -1,5 +1,4 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import Controller from "wms-core/Controller";
import config from "config";
import User from "wms-core/auth/models/User";
@ -19,29 +18,29 @@ export default class FileModel extends Model {
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
}
public readonly user_id!: number;
public readonly slug!: string;
public readonly real_name!: string;
public readonly storage_type!: FileStorage;
public readonly storage_path!: string;
public readonly size!: number;
public created_at?: Date;
public readonly ttl!: number;
public id?: number = undefined;
public readonly user_id?: number = undefined;
public readonly slug?: string = undefined;
public readonly real_name?: string = undefined;
public readonly storage_type?: FileStorage = undefined;
public readonly storage_path?: string = undefined;
public readonly size?: number = undefined;
public created_at?: Date = undefined;
public readonly ttl?: number = undefined;
protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug'));
this.addProperty('real_name', new Validator().defined().minLength(1).maxLength(259));
this.addProperty('storage_type', new Validator().defined().maxLength(64));
this.addProperty('storage_path', new Validator().defined().maxLength(1745));
this.addProperty('size', new Validator().defined().min(0));
this.addProperty('created_at', new Validator());
this.addProperty('ttl', new Validator().defined().min(0).max(4294967295));
this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('slug').defined().minLength(1).maxLength(259).unique(FileModel, 'slug').unique(URLRedirect, 'slug');
this.setValidation('real_name').defined().minLength(1).maxLength(259);
this.setValidation('storage_type').defined().maxLength(64);
this.setValidation('storage_path').defined().maxLength(1745);
this.setValidation('size').defined().min(0);
this.setValidation('ttl').defined().min(0).max(4294967295);
}
public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', {
slug: this.slug,
slug: this.slug!,
});
}
@ -49,7 +48,7 @@ export default class FileModel extends Model {
if (!this.created_at) return new Date();
if (this.ttl === 0) return null;
return new Date(this.created_at.getTime() + this.ttl * 1000);
return new Date(this.created_at.getTime() + this.ttl! * 1000);
}
public shouldBeDeleted(): boolean {

View File

@ -1,5 +1,4 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User";
import FileModel from "./FileModel";
import {Request} from "express";
@ -19,26 +18,25 @@ export default class URLRedirect extends Model {
return await this.paginate(req, perPage, this.select().where('user_id', user_id));
}
public readonly user_id!: number;
public readonly slug!: string;
public readonly target_url!: string;
public created_at?: Date;
public id?: number = undefined;
public readonly user_id?: number = undefined;
public readonly slug?: string = undefined;
public readonly target_url?: string = undefined;
public created_at?: Date = undefined;
constructor(data: any) {
super(data);
}
protected init(): void {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(URLRedirect, 'slug').unique(FileModel, 'slug'));
this.addProperty('target_url', new Validator().defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i));
this.addProperty('created_at', new Validator());
this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('slug').defined().minLength(1).maxLength(259).unique(URLRedirect, 'slug').unique(FileModel, 'slug');
this.setValidation('target_url').defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i);
}
public getURL(domain: string = config.get<string>('base_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', {
slug: this.slug,
slug: this.slug!,
});
}
}

View File

@ -1,41 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Review accounts' %}
{% block body %}
<h1>Accounts pending review</h1>
<div class="panel">
<table class="data-table">
<thead>
<tr>
<th class="shrink-col">#</th>
<th>Name</th>
<th>Main email</th>
<th>Registered at</th>
<th class="shrink-col">Action</th>
</tr>
</thead>
<tbody>
{% for user in accounts %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.mainEmail.getOrFail().email }}</td>
<td>{{ user.created_at.toISOString() }}</td>
<td>
<div class="max-content">
<a href="{{ route('approve-account', user.id) }}"
class="button success"><i data-feather="check"></i> Approve</a>
<a href="{{ route('reject-account', user.id) }}"
onclick="return confirm(`This will irrevocably delete the ${user.main_email} account.`)"
class="button danger"><i data-feather="x"></i> Reject</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Backend' %}
{% block body %}
<h1>App administration</h1>
<div class="container">
<div class="panel">
<nav>
<ul>
{% if approval_mode %}
<li>
<a href="{{ route('accounts-approval') }}">Accounts approval ({{ accounts_to_approve }})</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endblock %}

1322
yarn.lock

File diff suppressed because it is too large Load Diff