Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
cd3dd454aa | |||
6e630c2715 | |||
aa4a47230d | |||
d76a227c5c | |||
8b1e940d0a | |||
2879e014a8 | |||
4156f978fa | |||
0ad9143282 | |||
8e134fd353 | |||
ab3964ce73 | |||
adf50862f0 | |||
e8ab4527f7 | |||
55d2040dc3 | |||
0644bb7bda | |||
ea75813b98 | |||
a6e822ec64 | |||
0a11792557 | |||
1d4ea1720b | |||
9a862bc334 | |||
1f9fcbec58 |
@ -37,10 +37,10 @@
|
||||
mysql: {
|
||||
connectionLimit: 10,
|
||||
host: "127.0.0.1",
|
||||
user: "swaf",
|
||||
password: "swaf",
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "swaf",
|
||||
create_database_automatically: true,
|
||||
create_database_automatically: false,
|
||||
},
|
||||
redis: {
|
||||
host: "127.0.0.1",
|
||||
|
@ -16,9 +16,6 @@
|
||||
secure: true,
|
||||
allow_invalid_tls: false,
|
||||
},
|
||||
mysql: {
|
||||
create_database_automatically: false,
|
||||
},
|
||||
session: {
|
||||
cookie: {
|
||||
secure: true,
|
||||
|
@ -3,6 +3,9 @@
|
||||
approval_mode: true,
|
||||
},
|
||||
mysql: {
|
||||
host: "127.0.0.1",
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "swaf_test",
|
||||
create_database_automatically: true,
|
||||
},
|
||||
|
17
package.json
17
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "swaf",
|
||||
"version": "0.25.1",
|
||||
"version": "0.24.9",
|
||||
"description": "Structure Web Application Framework.",
|
||||
"repository": "https://eternae.ink/ashpie/swaf",
|
||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||
@ -25,9 +25,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/eslint-config": "sveltejs/eslint-config",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"@tsconfig/svelte": "^2.0.1",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/config": "^0.0.41",
|
||||
"@types/config": "^0.0.40",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
@ -38,10 +38,11 @@
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/mjml": "^4.0.4",
|
||||
"@types/mysql": "^2.15.10",
|
||||
"@types/node": "^20.15.0",
|
||||
"@types/node": "^16.11.10",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/nunjucks": "^3.1.3",
|
||||
"@types/on-finished": "^2.3.1",
|
||||
"@types/redis": "^2.8.18",
|
||||
"@types/require-from-string": "^1.2.0",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"@types/uuid": "^8.0.0",
|
||||
@ -49,7 +50,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.3.0",
|
||||
"@typescript-eslint/parser": "^5.3.0",
|
||||
"chokidar": "^3.5.1",
|
||||
"concurrently": "^7.0.0",
|
||||
"concurrently": "^6.0.0",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
@ -58,7 +59,7 @@
|
||||
"jest": "^27.3.1",
|
||||
"jest-resolve": "^27.3.1",
|
||||
"jest-ts-webcompat-resolver": "^1.0.0",
|
||||
"maildev": "^2.1.0",
|
||||
"maildev": "^1.1.0",
|
||||
"node-fetch": "^3.0.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"sass": "^1.32.12",
|
||||
@ -82,7 +83,7 @@
|
||||
"express-session": "^1.17.1",
|
||||
"formidable": "^2.0.1",
|
||||
"geoip-lite": "^1.4.2",
|
||||
"lucide": "^0.17.7",
|
||||
"lucide": "^0.16.17",
|
||||
"mjml": "^4.6.2",
|
||||
"mysql": "^2.18.1",
|
||||
"nanoid": "^3.1.20",
|
||||
@ -90,7 +91,7 @@
|
||||
"normalize.css": "^8.0.1",
|
||||
"nunjucks": "^3.2.1",
|
||||
"on-finished": "^2.3.0",
|
||||
"redis": "^4.0.4",
|
||||
"redis": "^3.0.2",
|
||||
"require-from-string": "^2.0.2",
|
||||
"rollup": "^2.42.3",
|
||||
"rollup-plugin-css-only": "^3.1.0",
|
||||
|
@ -18,7 +18,7 @@ import Extendable, {MissingComponentError} from "./Extendable.js";
|
||||
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
|
||||
import {logger, loggingContextMiddleware} from "./Logger.js";
|
||||
import SecurityError from "./SecurityError.js";
|
||||
import {doesFileExist, Type, WrappingError} from "./Utils.js";
|
||||
import {doesFileExist, Type} from "./Utils.js";
|
||||
import WebSocketListener from "./WebSocketListener.js";
|
||||
import TemplateError = nunjucks.lib.TemplateError;
|
||||
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
|
||||
@ -145,11 +145,6 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
|
||||
if (res.headersSent) return next(err);
|
||||
|
||||
logger.debug(err);
|
||||
if (err instanceof WrappingError) {
|
||||
logger.debug("-> Caused by", err.cause);
|
||||
}
|
||||
|
||||
// Transform single validation errors into a validation bag for convenience
|
||||
if (err instanceof ValidationError) {
|
||||
const bag = new ValidationBag();
|
||||
@ -331,8 +326,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
||||
for (const file of fs.readdirSync(configDir)) {
|
||||
const fullPath = path.resolve(configDir, file);
|
||||
const stats = fs.lstatSync(fullPath);
|
||||
if (stats.uid !== process.getuid?.())
|
||||
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid?.()}).`);
|
||||
if (stats.uid !== process.getuid())
|
||||
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid()}).`);
|
||||
|
||||
const mode = (stats.mode & parseInt('777', 8)).toString(8);
|
||||
if (mode !== '400')
|
||||
|
@ -1,51 +0,0 @@
|
||||
import config from "config";
|
||||
import cookie from "cookie";
|
||||
import cookieParser from "cookie-parser";
|
||||
import {Request} from "express";
|
||||
import {Session} from "express-session";
|
||||
import {IncomingMessage} from "http";
|
||||
import {WebSocket} from "ws";
|
||||
|
||||
import Application from "./Application.js";
|
||||
import RedisComponent from "./components/RedisComponent.js";
|
||||
import {logger} from "./Logger.js";
|
||||
import WebSocketListener from "./WebSocketListener.js";
|
||||
|
||||
export default abstract class SessionWebSocketListener<A extends Application> extends WebSocketListener<A> {
|
||||
|
||||
public async handle(socket: WebSocket, request: IncomingMessage): Promise<void> {
|
||||
socket.once('message', (data, isBinary) => {
|
||||
if (isBinary) return socket.close(1003);
|
||||
|
||||
const cookies = cookie.parse(data.toString());
|
||||
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
|
||||
|
||||
if (!sid) {
|
||||
socket.close(1002, 'Could not decrypt provided session cookie.');
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getApp().as(RedisComponent).getStore();
|
||||
store.get(sid, (err, session) => {
|
||||
if (err || !session) {
|
||||
logger.error(err, 'Error while initializing session in websocket for sid ' + sid);
|
||||
socket.close(1011);
|
||||
return;
|
||||
}
|
||||
|
||||
session.id = sid;
|
||||
|
||||
store.createSession(<Request>request, session);
|
||||
this.handleSessionSocket(socket, request, session as Session).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract handleSessionSocket(
|
||||
socket: WebSocket,
|
||||
request: IncomingMessage,
|
||||
session: Session,
|
||||
): Promise<void>;
|
||||
}
|
@ -4,12 +4,12 @@ import Application from "./Application.js";
|
||||
import AccountController from "./auth/AccountController.js";
|
||||
import AuthComponent from "./auth/AuthComponent.js";
|
||||
import AuthController from "./auth/AuthController.js";
|
||||
import AddUsedToMagicLinksMigration from "./auth/magic_link/migrations/AddUsedToMagicLinksMigration.js";
|
||||
import CreateMagicLinksTableMigration from "./auth/magic_link/migrations/CreateMagicLinksTableMigration.js";
|
||||
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration.js";
|
||||
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration.js";
|
||||
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
|
||||
import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
|
||||
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.js";
|
||||
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/migrations/MakeMagicLinksSessionNotUniqueMigration.js";
|
||||
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.js";
|
||||
import AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
|
||||
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
|
||||
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.js";
|
||||
@ -42,7 +42,6 @@ import BackendController from "./helpers/BackendController.js";
|
||||
import MailController from "./mail/MailController.js";
|
||||
import {MAGIC_LINK_MAIL} from "./Mails.js";
|
||||
import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js";
|
||||
import IncreaseMagicLinkTokenLengthMigration from "./auth/magic_link/migrations/IncreaseMagicLinkTokenLengthMigration.js";
|
||||
|
||||
export const MIGRATIONS = [
|
||||
CreateMigrationsTable,
|
||||
@ -53,7 +52,6 @@ export const MIGRATIONS = [
|
||||
MakeMagicLinksSessionNotUniqueMigration,
|
||||
AddUsedToMagicLinksMigration,
|
||||
AddNameChangedAtToUsersMigration,
|
||||
IncreaseMagicLinkTokenLengthMigration,
|
||||
];
|
||||
|
||||
export default class TestApp extends Application {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import {Session} from "express-session";
|
||||
import {IncomingMessage} from "http";
|
||||
import WebSocket from "ws";
|
||||
|
||||
@ -19,5 +20,6 @@ export default abstract class WebSocketListener<T extends Application> {
|
||||
public abstract handle(
|
||||
socket: WebSocket,
|
||||
request: IncomingMessage,
|
||||
session: Session | null,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
@ -155,7 +155,7 @@ h1 {
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
.icon.lucide-external-link {
|
||||
.icon.lucide-external-link { //todo add js
|
||||
--icon-size: 16px;
|
||||
margin-left: 4px;
|
||||
margin-top: -3px;
|
||||
@ -166,13 +166,6 @@ ul {
|
||||
list-style-type: '- ';
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--on-background);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.primary, .bold {
|
||||
--color: var(--primary-on-background);
|
||||
--background-color: var(--background);
|
||||
@ -256,7 +249,7 @@ button, .button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&:hover::after:not([disabled]) {
|
||||
&:hover::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
@ -267,10 +260,4 @@ button, .button {
|
||||
background-color: var(--on-background);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
@ -23,46 +23,6 @@
|
||||
thead tr:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
tbody td.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
padding: 8px;
|
||||
|
||||
.icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead th.col-grow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
tbody td.col-grow-cell {
|
||||
> * {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> * {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.data-table-container {
|
||||
|
@ -17,7 +17,6 @@ export default class WebsocketClient {
|
||||
const websocket = new WebSocket(this.websocketUrl);
|
||||
websocket.onopen = () => {
|
||||
console.debug('Websocket connected');
|
||||
websocket.send(document.cookie);
|
||||
};
|
||||
websocket.onmessage = (e) => {
|
||||
this.listener(websocket, e);
|
||||
|
@ -1,7 +1,5 @@
|
||||
import {createIcons, icons} from "lucide";
|
||||
|
||||
import {toLucideIconsPascalCase} from "../../common/StringUtils.js";
|
||||
|
||||
let hasAlreadyReplacedIcons = false;
|
||||
|
||||
export function replaceIcons(once: boolean): void {
|
||||
@ -11,7 +9,3 @@ export function replaceIcons(once: boolean): void {
|
||||
hasAlreadyReplacedIcons = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLucideIcon(iconName: string): boolean {
|
||||
return Object.keys(icons).indexOf(toLucideIconsPascalCase(iconName)) >= 0;
|
||||
}
|
||||
|
@ -15,87 +15,81 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate title="Account" description="Manage your account settings and data.">
|
||||
<section class="panel">
|
||||
<h2>
|
||||
<Icon name="user"/>
|
||||
Personal information
|
||||
</h2>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h2><Icon name="user"/> Personal information</h2>
|
||||
|
||||
{#if $locals.display_email_warning && $locals.emails.length <= 0}
|
||||
<Message type="warning" content="To avoid losing access to your account, please add an email address."/>
|
||||
{/if}
|
||||
{#if $locals.display_email_warning && $locals.emails.length <= 0}
|
||||
<Message type="warning" content="To avoid losing access to your account, please add an email address."/>
|
||||
{/if}
|
||||
|
||||
{#each personalInfoFields as field}
|
||||
<p>{field.name}: {field.value}</p>
|
||||
{/each}
|
||||
{#each personalInfoFields as field}
|
||||
<p>{field.name}: {field.value}</p>
|
||||
{/each}
|
||||
|
||||
{#if mainEmail}
|
||||
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if $locals.has_name_component}
|
||||
<NamePanel/>
|
||||
{/if}
|
||||
|
||||
{#if $locals.has_password_component}
|
||||
<PasswordPanel/>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails">
|
||||
<Icon name="shield"/>
|
||||
Email addresses
|
||||
</h2>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each emails as email}
|
||||
{#if email.id === $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Main</td>
|
||||
<td>{email.email}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each emails as email}
|
||||
{#if email.id !== $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Secondary</td>
|
||||
<td>{email.email}</td>
|
||||
<td class="actions">
|
||||
<Form action={route('set-main-email')} button
|
||||
submitIcon="refresh-ccw" submitText="Set as main address"
|
||||
submitClass="warning"
|
||||
confirm="Are you sure you want to set {email.email} as your main address?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
|
||||
<Form action={route('remove-email')} button
|
||||
submitIcon="trash" submitText="Remove" submitClass="danger"
|
||||
confirm="Are you sure you want to delete {email.email}?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if mainEmail}
|
||||
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasRoute('add-email')}
|
||||
{#if $locals.has_name_component}
|
||||
<NamePanel/>
|
||||
{/if}
|
||||
|
||||
{#if $locals.has_password_component}
|
||||
<PasswordPanel/>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><Icon name="shield"/> Email addresses</h2>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each emails as email}
|
||||
{#if email.id === $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Main</td>
|
||||
<td>{email.email}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each emails as email}
|
||||
{#if email.id !== $locals.user.main_email_id}
|
||||
<tr>
|
||||
<td>Secondary</td>
|
||||
<td>{email.email}</td>
|
||||
<td class="actions">
|
||||
<Form action={route('set-main-email')} button
|
||||
submitIcon="refresh-ccw" submitText="Set as main address" submitClass="warning"
|
||||
confirm="Are you sure you want to set {email.email} as your main address?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
|
||||
<Form action={route('remove-email')} button
|
||||
submitIcon="trash" submitText="Remove" submitClass="danger"
|
||||
confirm="Are you sure you want to delete {email.email}?">
|
||||
<Field type="hidden" name="id" value={email.id}/>
|
||||
</Form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if hasRoute('add-email')}
|
||||
<Form action={route('add-email')} class="sub-panel"
|
||||
submitIcon="plus" submitText="Add email address">
|
||||
<h3>Add an email address:</h3>
|
||||
@ -104,6 +98,7 @@
|
||||
hint="An email address we can use to identify you in case you lose access to your account"
|
||||
required/>
|
||||
</Form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -6,8 +6,8 @@
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
import {hasRoute, route} from "../../../common/Routing";
|
||||
|
||||
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
|
||||
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
|
||||
let registerUsingMagicLink = $locals.previousFormData?.['auth_method'] !== 'password';
|
||||
let loginUsingMagicLink = true;
|
||||
|
||||
let queryStr = '';
|
||||
let previousUrl: string = $locals.previousUrl as string | undefined;
|
||||
@ -22,72 +22,49 @@
|
||||
description="Join {$locals.app.name} and share your files!"
|
||||
h1="Authentication and registration">
|
||||
|
||||
{#if hasRoute('login')}
|
||||
<div class="container">
|
||||
{#if hasRoute('login')}
|
||||
<section class="panel">
|
||||
<h2>
|
||||
<Icon name="log-in"/>
|
||||
Log in
|
||||
</h2>
|
||||
<h2><Icon name="log-in"/> Log in</h2>
|
||||
|
||||
<Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
|
||||
<Field type="text" name="identifier" value={$locals.query?.identifier} icon="at-sign"
|
||||
hint={loginUsingMagicLink ? 'You will receive a magic link in your mailbox. Click on the link from any device to authenticate here.' : ''}
|
||||
placeholder="Your email address or username" required/>
|
||||
|
||||
{#if $locals.hasPassword}
|
||||
{#if loginUsingMagicLink}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button">
|
||||
<Icon name="key"/>
|
||||
Use password
|
||||
</button>
|
||||
{:else}
|
||||
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
|
||||
<button on:click={() => loginUsingMagicLink=true} type="button">
|
||||
<Icon name="mail"/>
|
||||
Use magic link
|
||||
</button>
|
||||
{/if}
|
||||
{#if !loginUsingMagicLink}
|
||||
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
|
||||
<button on:click={() => loginUsingMagicLink=true} type="button"><Icon name="mail"/> Use magic link</button>
|
||||
{:else}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button>
|
||||
{/if}
|
||||
<Field type="checkbox" name="persist_session" icon="clock"
|
||||
placeholder="Stay logged in on this computer."/>
|
||||
<Field type="checkbox" name="persist_session" icon="clock" placeholder="Stay logged in on this computer."/>
|
||||
</Form>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if hasRoute('register')}
|
||||
{#if hasRoute('register')}
|
||||
<section class="panel">
|
||||
<h2>
|
||||
<Icon name="user-plus"/>
|
||||
Register
|
||||
</h2>
|
||||
<h2><Icon name="user-plus"/> Register</h2>
|
||||
|
||||
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
|
||||
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
|
||||
|
||||
{#if $locals.hasUsername}
|
||||
{#if $locals.has_username}
|
||||
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
|
||||
placeholder="Choose your username"
|
||||
pattern="[0-9a-z_-]+" required/>
|
||||
{/if}
|
||||
|
||||
{#if registerUsingMagicLink || !$locals.canRegisterWithPassword}
|
||||
{#if registerUsingMagicLink}
|
||||
<Field type="email" name="identifier" icon="at-sign" placeholder="Your email address"
|
||||
hint="You will receive a magic link in your mailbox. Click on the link from any device to register here."
|
||||
required/>
|
||||
{#if $locals.canRegisterWithPassword}
|
||||
<button on:click={() => registerUsingMagicLink=false} type="button">
|
||||
<Icon name="key"/>
|
||||
Use password
|
||||
</button>
|
||||
{/if}
|
||||
<button on:click={() => registerUsingMagicLink=false} type="button"><Icon name="key"/>Use password</button>
|
||||
{:else}
|
||||
<Field type="password" name="password" icon="key" placeholder="Choose a password" required/>
|
||||
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password"
|
||||
required/>
|
||||
<button on:click={() => registerUsingMagicLink=true} type="button">
|
||||
<Icon name="at-sign"/>
|
||||
Use email address instead
|
||||
</button>
|
||||
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password" required/>
|
||||
<button on:click={() => registerUsingMagicLink=true} type="button"><Icon name="at-sign"/>Use email address instead</button>
|
||||
{/if}
|
||||
|
||||
<Field type="checkbox" name="terms" icon="file-text" required>
|
||||
@ -95,5 +72,6 @@
|
||||
</Field>
|
||||
</Form>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -8,24 +8,26 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
<div class="container">
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
|
||||
<h1>App administration</h1>
|
||||
<h1>App administration</h1>
|
||||
|
||||
<div class="panel">
|
||||
<nav>
|
||||
<ul>
|
||||
{#each menu as element}
|
||||
<li>
|
||||
<a href={element.link}>
|
||||
{#if element.display_icon !== null}
|
||||
<Icon name={element.display_icon}/>
|
||||
{/if}
|
||||
{element.display_string}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="panel">
|
||||
<nav>
|
||||
<ul>
|
||||
{#each menu as element}
|
||||
<li>
|
||||
<a href={element.link}>
|
||||
{#if element.display_icon !== null}
|
||||
<Icon name={element.display_icon}/>
|
||||
{/if}
|
||||
{element.display_string}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -2,9 +2,8 @@
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
export let title: string | undefined = undefined;
|
||||
export let title: string;
|
||||
export let content: string;
|
||||
export let buttonMode: boolean = false;
|
||||
|
||||
let contentNode: HTMLElement;
|
||||
let copiedOverlay: HTMLElement;
|
||||
@ -69,55 +68,28 @@
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.copied-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
|
||||
text-align: center;
|
||||
background-color: var(--success);
|
||||
}
|
||||
}
|
||||
|
||||
.button-mode-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copied-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
|
||||
text-align: center;
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.content.hidden {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
{#if buttonMode}
|
||||
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div>
|
||||
<button class="bold button-mode-button" on:click={copy} title="{content}">
|
||||
<Icon name="copy"/>
|
||||
<div class="copyable-text panel">
|
||||
<div class="title">{title}</div>
|
||||
<div class="content" bind:this={contentNode} on:click={selectAll}>{content}</div>
|
||||
<button class="bold copy-button" on:click={copy}><Icon name="copy"/></button>
|
||||
|
||||
{#if showCopiedOverlay}
|
||||
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}><Icon name="check"/></div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="copyable-text panel">
|
||||
{#if title}
|
||||
<div class="title">{title}</div>
|
||||
{/if}
|
||||
<div class="content" bind:this={contentNode} on:click={selectAll}>{content}</div>
|
||||
<button class="bold copy-button" on:click={copy}><Icon name="copy"/></button>
|
||||
|
||||
{#if showCopiedOverlay}
|
||||
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCopiedOverlay}
|
||||
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -3,13 +3,6 @@
|
||||
import Message from "./Message.svelte";
|
||||
|
||||
export let flashed = $locals.flash;
|
||||
const displayedCategories = [
|
||||
'success',
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
'error-alert',
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@ -20,7 +13,7 @@
|
||||
|
||||
<div class="messages">
|
||||
{#if flashed}
|
||||
{#each Object.entries(flashed).filter(entry => displayedCategories.indexOf(entry[0]) >= 0) as [key, bag], i}
|
||||
{#each Object.entries(flashed) as [key, bag], i}
|
||||
{#each bag as content}
|
||||
<Message type={key} content={content}/>
|
||||
{/each}
|
||||
|
@ -67,19 +67,16 @@
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
|
||||
@include large-ge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
|
||||
export let open: boolean = false;
|
||||
let hovered = false;
|
||||
|
||||
function onMouseEnter() {
|
||||
hovered = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovered = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../../scss/helpers";
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@include large-ge() {
|
||||
ul:not(.open) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
|
||||
@include surface(3);
|
||||
padding: 16px;
|
||||
border-top: 3px solid #ffffff1c;
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
top: calc(100% - 8px);
|
||||
|
||||
transition: transform 50ms linear;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&.open {
|
||||
transform: translateX(-50%) translateY(8px) rotateX(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@include medium-le {
|
||||
.icon-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="icon-container" class:open={open || hovered}>
|
||||
<Icon name="chevron-down"/>
|
||||
</div>
|
||||
<ul class:open={open || hovered} on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
|
||||
<slot/>
|
||||
</ul>
|
@ -6,37 +6,30 @@
|
||||
export let icon;
|
||||
export let text;
|
||||
export let action = false;
|
||||
export let hovered = false;
|
||||
|
||||
function onMouseEnter() {
|
||||
hovered = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovered = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../../scss/helpers";
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
@mixin aHover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
|
||||
@include darkMode {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
@include medium-le {
|
||||
&:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@include large-ge {
|
||||
&:not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -45,23 +38,17 @@
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
text-transform: uppercase;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
|
||||
@include medium-le {
|
||||
&:hover {
|
||||
@include aHover;
|
||||
@include darkMode {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include large-ge {
|
||||
&:hover > a {
|
||||
@include aHover;
|
||||
}
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:global(form) {
|
||||
@ -82,7 +69,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
|
||||
<li>
|
||||
{#if action}
|
||||
<Form action={href} submitIcon={icon} submitText={text}/>
|
||||
{:else}
|
||||
@ -90,5 +77,4 @@
|
||||
<Icon name={icon}/>
|
||||
<span class="tip">{text}</span></a>
|
||||
{/if}
|
||||
<slot/>
|
||||
</li>
|
||||
|
@ -18,16 +18,16 @@
|
||||
<p>Welcome to {$locals.app.name}!</p>
|
||||
|
||||
{#if hasAnyRoute('tests', 'design')}
|
||||
<nav>
|
||||
<ul>
|
||||
{#if hasRoute('tests')}
|
||||
<li><a href={route('tests')}>Frontend tests</a></li>
|
||||
{/if}
|
||||
{#if hasRoute('design')}
|
||||
<li><a href={route('design')}>Design test</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav>
|
||||
<ul>
|
||||
{#if hasRoute('tests')}
|
||||
<li><a href={route('tests')}>Frontend tests</a></li>
|
||||
{/if}
|
||||
{#if hasRoute('design')}
|
||||
<li><a href={route('design')}>Design test</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -8,12 +8,14 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
|
||||
<div class="panel">
|
||||
{#if $locals.err}
|
||||
<Message type="error" content={$locals.err}/>
|
||||
{:else}
|
||||
<Message type="success" content="Success!"/>
|
||||
<p>You can now close this page.</p>
|
||||
{/if}
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
{#if $locals.err}
|
||||
<Message type="error" content={$locals.err}/>
|
||||
{:else}
|
||||
<Message type="success" content="Success!"/>
|
||||
<p>You can now close this page.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -38,12 +38,14 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
|
||||
<div class="panel">
|
||||
<Message type="success" sticky
|
||||
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
|
||||
<Message type="info" discreet sticky raw
|
||||
content={`This link will be valid for ${countdown} and can only be used once.`}/>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<Message type="success" sticky
|
||||
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
|
||||
<Message type="info" discreet sticky raw
|
||||
content={`This link will be valid for ${countdown} and can only be used once.`}/>
|
||||
|
||||
<p class="center">Waiting for you to open the link...</p>
|
||||
<p class="center">Waiting for you to open the link...</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -3,12 +3,10 @@
|
||||
import BaseFooter from "./base/BaseFooter.svelte";
|
||||
import BaseHeader from "./base/BaseHeader.svelte";
|
||||
import CommonScripts from "./CommonScripts.svelte";
|
||||
import {locals} from '../../ts/stores.js';
|
||||
|
||||
export let title: string;
|
||||
export let h1: string = title;
|
||||
export let description: string;
|
||||
export let previewImageUrl: string | undefined = undefined;
|
||||
export let refresh_after: number | undefined = undefined;
|
||||
export let noHeader: boolean = false;
|
||||
export let noH1: boolean = false;
|
||||
@ -39,22 +37,8 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
|
||||
<title>{title || 'Undefined title'}</title>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content={title}>
|
||||
<meta property="og:url" content="{$locals.app.public_url + $locals.url}">
|
||||
<meta property="twitter:title" content={title}>
|
||||
<meta property="twitter:url" content={$locals.app.public_url + $locals.url}>
|
||||
{#if description}
|
||||
<meta name="description" content={description}>
|
||||
<meta property="og:description" content={description}>
|
||||
<meta property="twitter:description" content={description}>
|
||||
{/if}
|
||||
{#if previewImageUrl}
|
||||
<meta property="og:image" content={previewImageUrl}>
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:image" content={previewImageUrl}>
|
||||
{/if}
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
|
||||
|
@ -135,7 +135,7 @@
|
||||
background-image: url(../../img/logo.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-size: 64px;
|
||||
|
||||
opacity: 0.075;
|
||||
filter: contrast(0);
|
||||
|
@ -2,11 +2,8 @@
|
||||
import {locals} from "../../../ts/stores.js";
|
||||
import NavMenuItem from "../../components/NavMenuItem.svelte";
|
||||
import {hasRoute, route} from "../../../../common/Routing";
|
||||
import NavMenuDropdown from "../../components/NavMenuDropdown.svelte";
|
||||
import BaseNavMenuAuthAccountDropdownAdditionalLinks from "./BaseNavMenuAuthAccountDropdownAdditionalLinks.svelte";
|
||||
|
||||
export let noLoginLink = false;
|
||||
let accountItemHovered;
|
||||
</script>
|
||||
|
||||
{#if hasRoute('auth')}
|
||||
@ -15,12 +12,8 @@
|
||||
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
|
||||
{/if}
|
||||
|
||||
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'} bind:hovered={accountItemHovered}>
|
||||
<NavMenuDropdown bind:open={accountItemHovered}>
|
||||
<BaseNavMenuAuthAccountDropdownAdditionalLinks/>
|
||||
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
|
||||
</NavMenuDropdown>
|
||||
</NavMenuItem>
|
||||
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
|
||||
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
|
||||
{:else if !noLoginLink}
|
||||
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
|
||||
{/if}
|
||||
|
@ -8,19 +8,19 @@
|
||||
|
||||
export let type: string;
|
||||
export let name: string;
|
||||
type FieldValue = string | number | boolean | Record<string, FieldValue>;
|
||||
type FieldValue = string | number | Record<string, FieldValue>;
|
||||
export let value: FieldValue | undefined = undefined;
|
||||
export let initialValue: FieldValue | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let hint: string | undefined = undefined;
|
||||
export let extraData: string[] | undefined = undefined;
|
||||
export let icon: string | undefined = undefined;
|
||||
export let validation = $locals.flash.validation?.[0]?.[name] as { message: string, value?: string } | undefined;
|
||||
export let validation = $locals.validation?.[name] as { message: string, value?: string } | undefined;
|
||||
|
||||
const formId = getContext('formId');
|
||||
const fieldId = `${formId}-${name}-field`;
|
||||
|
||||
const previousFormData = $locals.flash.previousFormData?.[0] as Record<string, FieldValue> | undefined || {};
|
||||
const previousFormData = $locals.previousFormData as FieldValue | undefined || [];
|
||||
let previousFieldData = previousFormData[name];
|
||||
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
|
||||
|
||||
@ -45,13 +45,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput(e) {
|
||||
function focusInput() {
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
if (e.target !== label && e.target !== input) {
|
||||
input.click();
|
||||
}
|
||||
} else if (['file', 'color'].indexOf(input.type) >= 0) {
|
||||
if (['file', 'checkbox', 'color'].indexOf(input.type) >= 0) {
|
||||
input.click();
|
||||
} else {
|
||||
input.focus();
|
||||
@ -62,37 +58,31 @@
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
switch (this.type) {
|
||||
case 'number':
|
||||
case 'range':
|
||||
value = +this.value;
|
||||
break;
|
||||
case 'file':
|
||||
handleFileInput();
|
||||
break;
|
||||
case 'datetime-local':
|
||||
value = dateToIsoString(new Date(this.value));
|
||||
break;
|
||||
case 'checkbox':
|
||||
value = !!this.checked;
|
||||
break;
|
||||
default:
|
||||
value = this.value;
|
||||
break;
|
||||
// in here, you can switch on type and implement
|
||||
// whatever behaviour you need
|
||||
value = type.match(/^(number|range)$/)
|
||||
? +this.value
|
||||
: this.value;
|
||||
|
||||
if (this.type === 'file') {
|
||||
handleFileInput();
|
||||
}
|
||||
|
||||
if (this.type === 'datetime-local') {
|
||||
value = dateToIsoString(new Date(this.value));
|
||||
}
|
||||
}
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let label: HTMLLabelElement;
|
||||
|
||||
function chooseFile() {
|
||||
input.click();
|
||||
}
|
||||
|
||||
export let fileList: FileList | undefined = undefined;
|
||||
let files: FileList | undefined;
|
||||
|
||||
function handleFileInput() {
|
||||
fileList = input.files;
|
||||
files = input.files;
|
||||
}
|
||||
|
||||
let focused = false;
|
||||
@ -108,7 +98,6 @@
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: row;
|
||||
@ -434,8 +423,7 @@
|
||||
on:focusout={() => focused = false}></textarea>
|
||||
</div>
|
||||
{:else if type === 'checkbox'}
|
||||
<input {type} {name} id={fieldId} checked={!!value} {...$$restProps} bind:this={input}
|
||||
on:change={handleInput}>
|
||||
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps} bind:this={input}>
|
||||
{:else if type === 'datetime-local'}
|
||||
<input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}>
|
||||
<input type="hidden" {name} {value}>
|
||||
@ -446,15 +434,15 @@
|
||||
|
||||
<div class="sections">
|
||||
{#if type !== 'duration'}
|
||||
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
|
||||
<label for={fieldId}>{@html placeholder || ''}
|
||||
<slot/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if type === 'file'}
|
||||
{#if fileList}
|
||||
{#if files}
|
||||
<div class="files">
|
||||
{#each fileList as file}
|
||||
{#each files as file}
|
||||
<div class="file">
|
||||
<div class="name" title="Type: {file.type}">
|
||||
<Icon name="file"/> {file.name}
|
||||
|
@ -11,7 +11,6 @@
|
||||
export let submitText: string;
|
||||
export let submitIcon: string;
|
||||
export let submitClass: string = undefined;
|
||||
export let submitDisabled: boolean = false;
|
||||
export let isBoldSubmit: boolean = true;
|
||||
export let resetButton: boolean = false;
|
||||
export let confirm: string = undefined;
|
||||
@ -20,11 +19,11 @@
|
||||
const formId = nextAvailableFormId++;
|
||||
setContext('formId', formId);
|
||||
|
||||
export let onSubmit = function(e) {
|
||||
if (submitDisabled || confirm && !window.confirm(confirm)) {
|
||||
function handleSubmit(e) {
|
||||
if (confirm && !window.confirm(confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@ -39,7 +38,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<form {action} method="POST" id="{formId}-form" on:submit={onSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
|
||||
<form {action} method="POST" id="{formId}-form" on:submit={handleSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
|
||||
<CsrfTokenField/>
|
||||
<slot/>
|
||||
<div class="form-controls">
|
||||
@ -47,7 +46,7 @@
|
||||
<button type="reset"><Icon name="trash"/>Reset</button>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
|
||||
<button type="submit" class={submitClass} class:bold={isBoldSubmit}>
|
||||
{#if submitIcon}
|
||||
<Icon name={submitIcon}/>
|
||||
{/if}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
|
||||
import {replaceIcons} from "../../ts/icons.js";
|
||||
import {afterUpdate, onMount} from "svelte";
|
||||
|
||||
export let name: string;
|
||||
@ -39,9 +39,9 @@
|
||||
</style>
|
||||
|
||||
{#if name}
|
||||
{#if isLucideIcon(name) >= 0 }
|
||||
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{:else}
|
||||
{#if name.startsWith('fa') }
|
||||
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{:else}
|
||||
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -12,15 +12,6 @@ import UserNameComponent from "./models/UserNameComponent.js";
|
||||
import UserPasswordComponent from "./password/UserPasswordComponent.js";
|
||||
|
||||
export default class AuthController extends Controller {
|
||||
public static flashSuccessfulAuthenticationWelcomeMessage(
|
||||
user: User,
|
||||
req: Request,
|
||||
messagePrefix: string,
|
||||
): void {
|
||||
const name = user.asOptional(UserNameComponent)?.getName();
|
||||
req.flash('success', `${messagePrefix} Welcome${name ? `, ${name}` : ''}.`);
|
||||
}
|
||||
|
||||
public getRoutesPrefix(): string {
|
||||
return '/auth';
|
||||
}
|
||||
@ -44,12 +35,10 @@ export default class AuthController extends Controller {
|
||||
|
||||
const userModelFactory = ModelFactory.get(User);
|
||||
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
|
||||
const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
|
||||
res.formatViewData('auth/auth', {
|
||||
auth_methods: authGuard.getAuthMethodNames(),
|
||||
hasUsername: hasUsername,
|
||||
hasPassword: hasPassword,
|
||||
canRegisterWithPassword: hasUsername && hasPassword,
|
||||
has_username: hasUsername,
|
||||
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -160,9 +160,6 @@ export default class AuthGuard {
|
||||
throw new PendingApprovalAuthError();
|
||||
}
|
||||
|
||||
// Mark auth proof as used
|
||||
await proof.use?.();
|
||||
|
||||
// Login
|
||||
session.isAuthenticated = true;
|
||||
session.persistent = persistSession;
|
||||
|
@ -38,10 +38,4 @@ export default interface AuthProof<R> {
|
||||
* instance.
|
||||
*/
|
||||
revoke(): Promise<void>;
|
||||
|
||||
/**
|
||||
* This method is called when the AuthProof was used in a successful login attempt.
|
||||
* If you modify the AuthProof, you should make sure changes are persistent.
|
||||
*/
|
||||
use?(): Promise<void>;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Migration from "../../../db/Migration.js";
|
||||
import Migration from "../../db/Migration.js";
|
||||
|
||||
export default class AddUsedToMagicLinksMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
@ -1,6 +1,6 @@
|
||||
import Migration from "../../../db/Migration.js";
|
||||
import ModelFactory from "../../../db/ModelFactory.js";
|
||||
import MagicLink from "../../models/MagicLink.js";
|
||||
import Migration from "../../db/Migration.js";
|
||||
import ModelFactory from "../../db/ModelFactory.js";
|
||||
import MagicLink from "../models/MagicLink.js";
|
||||
|
||||
export default class CreateMagicLinksTableMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
@ -83,7 +83,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
|
||||
}
|
||||
|
||||
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> {
|
||||
const geo = req.ip ? geoip.lookup(req.ip) : null;
|
||||
const geo = geoip.lookup(req.ip);
|
||||
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
|
||||
|
||||
if (isRegistration) {
|
||||
|
@ -12,7 +12,6 @@ import Mail from "../../mail/Mail.js";
|
||||
import MailTemplate from "../../mail/MailTemplate.js";
|
||||
import Throttler from "../../Throttler.js";
|
||||
import AuthComponent, {AuthMiddleware} from "../AuthComponent.js";
|
||||
import AuthController from "../AuthController.js";
|
||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
|
||||
import MagicLink from "../models/MagicLink.js";
|
||||
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js";
|
||||
@ -142,7 +141,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
}
|
||||
|
||||
if (await validLink.isAuthorized()) {
|
||||
validLink.useLink();
|
||||
validLink.use();
|
||||
await validLink.save();
|
||||
await this.performAction(validLink, req, res);
|
||||
return;
|
||||
@ -199,7 +198,8 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
|
||||
if (!res.headersSent && user) {
|
||||
// Auth success
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
|
||||
const name = user.asOptional(UserNameComponent)?.getName();
|
||||
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
break;
|
||||
|
@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import Application from "../../Application.js";
|
||||
import SessionWebSocketListener from "../../SessionWebSocketListener.js";
|
||||
import WebSocketListener from "../../WebSocketListener.js";
|
||||
import MagicLink from "../models/MagicLink.js";
|
||||
|
||||
export default class MagicLinkWebSocketListener<A extends Application> extends SessionWebSocketListener<A> {
|
||||
export default class MagicLinkWebSocketListener<A extends Application> extends WebSocketListener<A> {
|
||||
private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
|
||||
|
||||
public refreshMagicLink(sessionId: string): void {
|
||||
@ -16,7 +16,13 @@ export default class MagicLinkWebSocketListener<A extends Application> extends S
|
||||
}
|
||||
}
|
||||
|
||||
public async handleSessionSocket(socket: WebSocket, request: IncomingMessage, session: Session): Promise<void> {
|
||||
public async handle(socket: WebSocket, request: IncomingMessage, session: Session | null): Promise<void> {
|
||||
// Drop if requested without session
|
||||
if (!session) {
|
||||
socket.close(1002, 'Session is required for this request.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Refuse any incoming data
|
||||
socket.on('message', () => {
|
||||
socket.close(1003);
|
||||
@ -31,22 +37,19 @@ export default class MagicLinkWebSocketListener<A extends Application> extends S
|
||||
// Refresh if immediately applicable
|
||||
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
||||
socket.send('refresh');
|
||||
const reason = magicLink ?
|
||||
'Magic link state changed.' :
|
||||
'Magic link not found for session ' + session.id;
|
||||
socket.close(1000, reason);
|
||||
socket.close(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const validityTimeout = setTimeout(() => {
|
||||
socket.send('refresh');
|
||||
socket.close(1000, 'Timed out');
|
||||
socket.close(1000);
|
||||
}, magicLink.getExpirationDate().getTime() - new Date().getTime());
|
||||
|
||||
const f = () => {
|
||||
clearTimeout(validityTimeout);
|
||||
socket.send('refresh');
|
||||
socket.close(1000, 'Closed by server');
|
||||
socket.close(1000);
|
||||
};
|
||||
|
||||
socket.on('close', () => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Migration from "../../../db/Migration.js";
|
||||
import Migration from "../../db/Migration.js";
|
||||
|
||||
export default class MakeMagicLinksSessionNotUniqueMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
@ -1,13 +0,0 @@
|
||||
import Migration from "../../../db/Migration.js";
|
||||
|
||||
export default class IncreaseMagicLinkTokenLengthMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query(`ALTER TABLE magic_links
|
||||
MODIFY COLUMN token VARCHAR(128)`);
|
||||
}
|
||||
|
||||
public async rollback(): Promise<void> {
|
||||
await this.query(`ALTER TABLE magic_links
|
||||
MODIFY COLUMN token CHAR(96)`);
|
||||
}
|
||||
}
|
@ -26,7 +26,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
||||
protected init(): void {
|
||||
this.setValidation('session_id').defined().length(32);
|
||||
this.setValidation('email').defined().regexp(EMAIL_REGEX);
|
||||
this.setValidation('token').defined().maxLength(128);
|
||||
this.setValidation('token').defined().length(96);
|
||||
this.setValidation('action_type').defined().maxLength(64);
|
||||
this.setValidation('original_url').defined().maxLength(1745);
|
||||
this.setValidation('authorized').defined();
|
||||
@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
||||
return this.used;
|
||||
}
|
||||
|
||||
public useLink(): void {
|
||||
public use(): void {
|
||||
this.used = true;
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,6 @@ import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
|
||||
import {ServerError} from "../../HttpError.js";
|
||||
import Throttler from "../../Throttler.js";
|
||||
import AuthComponent from "../AuthComponent.js";
|
||||
import AuthController from "../AuthController.js";
|
||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
|
||||
import AuthMethod from "../AuthMethod.js";
|
||||
import User from "../models/User.js";
|
||||
@ -74,7 +73,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
Throttler.throttle('login_failed_attempts_user', 3, 3 * 60 * 1000, // 3min
|
||||
user.getOrFail('id').toString(), 1000, 60 * 1000); // 1min
|
||||
Throttler.throttle('login_failed_attempts_ip', 50, 60 * 1000, // 1min
|
||||
req.ip || "", 1000, 3600 * 1000); // 1h
|
||||
req.ip, 1000, 3600 * 1000); // 1h
|
||||
|
||||
if (e instanceof PendingApprovalAuthError) {
|
||||
req.flash('error', 'Your account is still being reviewed.');
|
||||
@ -90,7 +89,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
}
|
||||
}
|
||||
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
|
||||
req.flash('success', `Welcome, ${user.name}.`);
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
|
||||
@ -98,7 +97,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
if (!ModelFactory.get(User).hasComponent(UserNameComponent))
|
||||
throw new ServerError('Cannot register with password without UserNameComponent.');
|
||||
|
||||
Throttler.throttle('register_password', 10, 30000, req.ip || "");
|
||||
Throttler.throttle('register_password', 10, 30000, req.ip);
|
||||
|
||||
req.body.identifier = identifier;
|
||||
|
||||
@ -137,9 +136,8 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
}
|
||||
|
||||
const user = await passwordAuthProof.getResource();
|
||||
if (!user) throw new Error('Password auth proof has no user.');
|
||||
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Your account was successfully created!');
|
||||
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
export const toLucideIconsPascalCase = (string: string): string =>
|
||||
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());
|
@ -67,7 +67,7 @@ export class Time {
|
||||
public static humanizeDuration(
|
||||
duration: number,
|
||||
short: boolean = false,
|
||||
skipNumberTextPartWhenSingular: boolean = false,
|
||||
skipOneUnitNumber: boolean = false,
|
||||
units: TimeUnit[] = [
|
||||
this.UNITS.SECOND,
|
||||
this.UNITS.MINUTE,
|
||||
@ -77,13 +77,13 @@ export class Time {
|
||||
this.UNITS.YEAR,
|
||||
],
|
||||
): string {
|
||||
for (const unit of units.reverse()) {
|
||||
if (duration >= unit.milliseconds) {
|
||||
const amount = Math.floor(duration / unit.milliseconds);
|
||||
const unitTextPart = short ?
|
||||
unit.shortName :
|
||||
' ' + unit.longName + (amount > 1 ? 's' : '');
|
||||
return (amount > 1 || !skipNumberTextPartWhenSingular ? amount : '') + unitTextPart;
|
||||
for (let i = units.length - 1; i > 0; i--) {
|
||||
if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) {
|
||||
const amount = Math.floor(duration / units[i - 1].milliseconds);
|
||||
const unit = short ?
|
||||
units[i - 1].shortName :
|
||||
' ' + units[i - 1].longName + (amount > 1 ? 's' : '');
|
||||
return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,19 @@ import ApplicationComponent from "../ApplicationComponent.js";
|
||||
export default class FormHelperComponent extends ApplicationComponent {
|
||||
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.setLazyLocal('validation', () => {
|
||||
const validation = req.flash('validation');
|
||||
return validation.length > 0 ? validation[0] : null;
|
||||
});
|
||||
|
||||
res.setLazyLocal('previousFormData', () => {
|
||||
const previousFormData = req.flash('previousFormData');
|
||||
return previousFormData.length > 0 ? previousFormData[0] : null;
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (['GET', 'POST'].find(m => m === req.method)) {
|
||||
if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import config from "config";
|
||||
import {Express} from "express";
|
||||
import session, {Store} from "express-session";
|
||||
import {createClient, RedisClientType} from "redis";
|
||||
import redis, {RedisClient} from "redis";
|
||||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import CacheProvider from "../CacheProvider.js";
|
||||
@ -9,30 +9,22 @@ import {logger} from "../Logger.js";
|
||||
|
||||
export default class RedisComponent extends ApplicationComponent implements CacheProvider {
|
||||
private readonly prefix: string = config.get('redis.prefix');
|
||||
private redisClient?: RedisClientType;
|
||||
private redisClient?: RedisClient;
|
||||
private store: Store = new RedisStore(this);
|
||||
|
||||
public async start(_app: Express): Promise<void> {
|
||||
const redisUrl = `redis://${config.get('redis.host')}:${config.get('redis.port')}`;
|
||||
this.redisClient = createClient({
|
||||
url: redisUrl,
|
||||
this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), {
|
||||
password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
|
||||
});
|
||||
this.redisClient.on('error', (err: Error) => {
|
||||
logger.error(err, 'An error occurred with redis.');
|
||||
});
|
||||
|
||||
await this.redisClient.connect();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const redisClient = this.redisClient;
|
||||
if (redisClient) {
|
||||
await this.close('Redis connection', callback => {
|
||||
redisClient.quit()
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
});
|
||||
await this.close('Redis connection', callback => redisClient.quit(callback));
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,14 +33,24 @@ export default class RedisComponent extends ApplicationComponent implements Cach
|
||||
}
|
||||
|
||||
public isReady(): boolean {
|
||||
return this.redisClient !== undefined && this.redisClient.isOpen;
|
||||
return this.redisClient !== undefined && this.redisClient.connected;
|
||||
}
|
||||
|
||||
public async get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T> {
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
return (await this.redisClient.get(this.prefix + key)|| defaultValue || undefined) as T;
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.redisClient.get(this.prefix + key, (err, val) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve((val || defaultValue || undefined) as T);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
@ -56,27 +58,49 @@ export default class RedisComponent extends ApplicationComponent implements Cach
|
||||
}
|
||||
|
||||
public async forget(key: string): Promise<void> {
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redisClient.del(this.prefix + key);
|
||||
this.redisClient.del(this.prefix + key, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async remember(key: string, value: string, ttl: number): Promise<void> {
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redisClient.pSetEx(this.prefix + key, ttl, value);
|
||||
this.redisClient.psetex(this.prefix + key, ttl, value, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async persist(key: string, ttl: number): Promise<void> {
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redisClient.pExpire(this.prefix + key, ttl);
|
||||
this.redisClient.pexpire(this.prefix + key, ttl, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,9 +29,8 @@ export default class SessionComponent extends ApplicationComponent {
|
||||
store: this.storeComponent.getStore(),
|
||||
resave: false,
|
||||
cookie: {
|
||||
httpOnly: false,
|
||||
httpOnly: true,
|
||||
secure: config.get('session.cookie.secure'),
|
||||
sameSite: 'strict',
|
||||
},
|
||||
rolling: true,
|
||||
}));
|
||||
@ -62,7 +61,13 @@ export default class SessionComponent extends ApplicationComponent {
|
||||
|
||||
// Views flash function
|
||||
res.setLazyLocal('flash', () => {
|
||||
return req.flash();
|
||||
return {
|
||||
info: req.flash('info'),
|
||||
success: req.flash('success'),
|
||||
warning: req.flash('warning'),
|
||||
error: req.flash('error'),
|
||||
'error-alert': req.flash('error-alert'),
|
||||
};
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
@ -1,5 +1,8 @@
|
||||
import config from "config";
|
||||
import {Express, Router} from "express";
|
||||
import cookie from "cookie";
|
||||
import cookieParser from "cookie-parser";
|
||||
import {Express, Request, Router} from "express";
|
||||
import {Session} from "express-session";
|
||||
import {WebSocketServer} from "ws";
|
||||
|
||||
import Application from "../Application.js";
|
||||
@ -42,11 +45,37 @@ export default class WebSocketServerComponent extends ApplicationComponent {
|
||||
if (!listener) {
|
||||
socket.close(1002, `Path not found ${request.url}`);
|
||||
return;
|
||||
} else if (!request.headers.cookie) {
|
||||
listener.handle(socket, request, null).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Websocket on ${request.url}`);
|
||||
listener.handle(socket, request).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
|
||||
const cookies = cookie.parse(request.headers.cookie);
|
||||
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
|
||||
|
||||
if (!sid) {
|
||||
socket.close(1002);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = app.as(RedisComponent).getStore();
|
||||
store.get(sid, (err, session) => {
|
||||
if (err || !session) {
|
||||
logger.error(err, 'Error while initializing session in websocket.');
|
||||
socket.close(1011);
|
||||
return;
|
||||
}
|
||||
|
||||
session.id = sid;
|
||||
|
||||
store.createSession(<Request>request, session);
|
||||
listener.handle(socket, request, session as Session).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -59,8 +59,8 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
|
||||
|
||||
[key: string]: ModelFieldData;
|
||||
|
||||
public constructor(isNew: boolean) {
|
||||
const factory = ModelFactory.get(this.constructor as ModelType<Model>);
|
||||
public constructor(factory: ModelFactory<never>, isNew: boolean) {
|
||||
if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
|
||||
this._factory = factory;
|
||||
this.init?.();
|
||||
this._exists = !isNew;
|
||||
@ -245,10 +245,10 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelType<M extends Model> {
|
||||
export interface ModelType<M extends Model> extends Type<M> {
|
||||
table: string;
|
||||
|
||||
new(isNew: boolean): M;
|
||||
new(factory: ModelFactory<never>, isNew: boolean): M;
|
||||
|
||||
getPrimaryKeyFields(): (keyof M & string)[];
|
||||
|
||||
|
@ -40,7 +40,7 @@ export default class ModelFactory<M extends Model> {
|
||||
}
|
||||
|
||||
public create(data: Pick<M, keyof M>, isNewModel: boolean): M {
|
||||
const model = new this.modelType(isNewModel);
|
||||
const model = new this.modelType(this as unknown as ModelFactory<never>, isNewModel);
|
||||
for (const component of this.components) {
|
||||
model.addComponent(new component(model));
|
||||
}
|
||||
|
@ -2,14 +2,14 @@ import {Connection} from "mysql";
|
||||
|
||||
import {ServerError} from "../HttpError.js";
|
||||
import Model, {ModelType} from "./Model.js";
|
||||
import ModelQuery, {ModelFieldData, WhereTest} from "./ModelQuery.js";
|
||||
import ModelQuery, {WhereTest} from "./ModelQuery.js";
|
||||
|
||||
export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
||||
|
||||
export default class Validator<V extends ModelFieldData> {
|
||||
export default class Validator<V> {
|
||||
public static async validate(
|
||||
validationMap: { [p: string]: Validator<ModelFieldData> },
|
||||
body: { [p: string]: ModelFieldData },
|
||||
validationMap: { [p: string]: Validator<unknown> },
|
||||
body: { [p: string]: unknown },
|
||||
): Promise<void> {
|
||||
const bag = new ValidationBag();
|
||||
|
||||
|
@ -27,7 +27,7 @@ describe('Register with username and password (password)', () => {
|
||||
|
||||
test('General case', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') ?? [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
// Register user
|
||||
@ -90,7 +90,7 @@ describe('Register with username and password (password)', () => {
|
||||
.count()).toStrictEqual(0);
|
||||
|
||||
const res1 = await agent.get('/csrf').expect(200);
|
||||
const cookies = res1.get('Set-Cookie') || [];
|
||||
const cookies = res1.get('Set-Cookie');
|
||||
const csrf = res1.text;
|
||||
|
||||
// Register user
|
||||
@ -139,7 +139,7 @@ describe('Register with username and password (password)', () => {
|
||||
describe('Register with email (magic_link)', () => {
|
||||
test('General case', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
|
||||
@ -180,7 +180,7 @@ describe('Register with email (magic_link)', () => {
|
||||
|
||||
test('Cannot register without specifying username', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
res = await agent.post('/auth/register')
|
||||
@ -198,7 +198,7 @@ describe('Register with email (magic_link)', () => {
|
||||
|
||||
test('Cannot register taken username', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -240,7 +240,7 @@ describe('Register with email (magic_link)', () => {
|
||||
|
||||
test('Cannot register taken email', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -284,7 +284,7 @@ describe('Register with email (magic_link)', () => {
|
||||
describe('Authenticate with username and password (password)', () => {
|
||||
test('Force auth_method', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -319,7 +319,7 @@ describe('Authenticate with username and password (password)', () => {
|
||||
|
||||
test('Automatic auth_method', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -352,7 +352,7 @@ describe('Authenticate with username and password (password)', () => {
|
||||
|
||||
test('Non-existing username', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -385,7 +385,7 @@ describe('Authenticate with username and password (password)', () => {
|
||||
|
||||
test('No password user', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -440,14 +440,13 @@ describe('Authenticate with username and password (password)', () => {
|
||||
describe('Authenticate with email (magic_link)', () => {
|
||||
test('Force auth_method', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
await agent.get('/is-auth').set('Cookie', cookies).expect(401);
|
||||
|
||||
// Authenticate
|
||||
// TODO deprecated querystring => URLSearchParams
|
||||
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
@ -465,7 +464,7 @@ describe('Authenticate with email (magic_link)', () => {
|
||||
|
||||
test('Automatic auth_method', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -488,7 +487,7 @@ describe('Authenticate with email (magic_link)', () => {
|
||||
|
||||
test('Non-existing email (forced auth_method)', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -509,7 +508,7 @@ describe('Authenticate with email (magic_link)', () => {
|
||||
|
||||
test('Non-existing email (automatic auth_method)', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -531,7 +530,7 @@ describe('Authenticate with email (magic_link)', () => {
|
||||
describe('Authenticate with email and password (password)', () => {
|
||||
test('Prepare user', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -570,7 +569,7 @@ describe('Authenticate with email and password (password)', () => {
|
||||
|
||||
test('Force auth_method', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -605,7 +604,7 @@ describe('Authenticate with email and password (password)', () => {
|
||||
|
||||
test('Automatic auth_method', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -642,7 +641,7 @@ describe('Change password', () => {
|
||||
let cookies: string[], csrf: string;
|
||||
test('Prepare user', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -822,7 +821,7 @@ describe('Change password', () => {
|
||||
|
||||
test('Can\'t remove password without contact email', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -864,7 +863,7 @@ describe('Change username', () => {
|
||||
let cookies: string[], csrf: string;
|
||||
test('Prepare user', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -963,7 +962,7 @@ describe('Manage email addresses', () => {
|
||||
let cookies: string[], csrf: string;
|
||||
test('Prepare user', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -1170,7 +1169,7 @@ describe('Session persistence', () => {
|
||||
|
||||
test('Not persistent at registration', async () => {
|
||||
let res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -1186,12 +1185,12 @@ describe('Session persistence', () => {
|
||||
|
||||
await followMagicLinkFromMail(agent, cookies);
|
||||
|
||||
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
|
||||
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
|
||||
|
||||
res = await agent.get('/csrf')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
|
||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
|
||||
|
||||
// Logout
|
||||
await agent.post('/auth/logout')
|
||||
@ -1218,7 +1217,7 @@ describe('Session persistence', () => {
|
||||
const res = await agent.get('/csrf')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
|
||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
|
||||
|
||||
// Logout
|
||||
await agent.post('/auth/logout')
|
||||
@ -1245,7 +1244,7 @@ describe('Session persistence', () => {
|
||||
const res = await agent.get('/csrf')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
|
||||
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
|
||||
|
||||
// Logout
|
||||
await agent.post('/auth/logout')
|
||||
|
@ -25,7 +25,7 @@ describe('Register with username and password (password)', () => {
|
||||
|
||||
test('General case', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
csrf = res.text;
|
||||
|
||||
// Register user
|
||||
@ -63,7 +63,7 @@ describe('Register with username and password (password)', () => {
|
||||
describe('Register with email (magic_link)', () => {
|
||||
test('General case', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
|
||||
@ -106,7 +106,7 @@ describe('Register with email (magic_link)', () => {
|
||||
describe('Authenticate with username and password (password)', () => {
|
||||
test('Force auth_method', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
@ -134,7 +134,7 @@ describe('Authenticate with username and password (password)', () => {
|
||||
describe('Authenticate with email (magic_link)', () => {
|
||||
test('Force auth_method', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Not authenticated
|
||||
|
@ -22,7 +22,7 @@ test('Approval Mode', () => {
|
||||
describe('Register with username and password (password)', () => {
|
||||
test('Must be disabled', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
// Register user
|
||||
@ -43,7 +43,7 @@ describe('Register with username and password (password)', () => {
|
||||
describe('Register with email (magic_link)', () => {
|
||||
test('General case', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -77,7 +77,7 @@ describe('Register with email (magic_link)', () => {
|
||||
|
||||
test('Cannot register taken email', async () => {
|
||||
const res = await agent.get('/csrf').expect(200);
|
||||
const cookies = res.get('Set-Cookie') || [];
|
||||
const cookies = res.get('Set-Cookie');
|
||||
const csrf = res.text;
|
||||
|
||||
await agent.post('/auth/register')
|
||||
@ -106,7 +106,7 @@ describe('Register with email (magic_link)', () => {
|
||||
const res2 = await agent.get('/csrf').expect(200);
|
||||
|
||||
await agent.post('/auth/register')
|
||||
.set('Cookie', res2.get('Set-Cookie') || [])
|
||||
.set('Cookie', res2.get('Set-Cookie'))
|
||||
.send({
|
||||
csrf: res2.text,
|
||||
auth_method: 'magic_link',
|
||||
|
@ -39,7 +39,7 @@ describe('Test CSRF protection', () => {
|
||||
.expect(401)
|
||||
.then(res => {
|
||||
expect(res.text).toContain(`You weren't assigned any CSRF token.`);
|
||||
cookies = res.get('Set-Cookie') || [];
|
||||
cookies = res.get('Set-Cookie');
|
||||
|
||||
agent.get('/')
|
||||
.set('Cookie', cookies)
|
||||
|
@ -71,14 +71,14 @@ export function authAppProvider(withUsername: boolean = true, approvalMode: bool
|
||||
const proof = await PasswordAuthProof.getProofForSession(req.getSession());
|
||||
if (proof) res.sendStatus(200);
|
||||
else res.sendStatus(404);
|
||||
}, 'has-any-password-auth-proof');
|
||||
}, 'is-auth');
|
||||
this.get('/has-any-magic-link', async (req, res) => {
|
||||
const proofs = await MagicLink.select()
|
||||
.where('session_id', req.getSession().id)
|
||||
.get();
|
||||
if (proofs.length > 0) res.sendStatus(200);
|
||||
else res.sendStatus(404);
|
||||
}, 'has-any-magic-link');
|
||||
}, 'is-auth');
|
||||
}
|
||||
}());
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user