Compare commits

..

20 Commits

Author SHA1 Message Date
cd3dd454aa Merge branch 'develop' 2021-11-28 21:28:42 +01:00
6e630c2715 Merge branch 'develop' 2021-11-21 20:25:08 +01:00
aa4a47230d Merge branch 'develop' 2021-11-21 17:08:00 +01:00
d76a227c5c Merge branch 'develop' 2021-11-21 16:50:59 +01:00
8b1e940d0a Merge branch 'develop' 2021-11-20 19:53:14 +01:00
2879e014a8 Merge branch 'develop' 2021-11-20 19:09:26 +01:00
4156f978fa Merge branch 'develop' 2021-11-20 15:40:38 +01:00
0ad9143282 Merge branch 'develop' 2021-11-10 17:59:59 +01:00
8e134fd353 Merge branch 'develop' 2021-11-10 16:44:12 +01:00
ab3964ce73 Merge branch 'develop' 2021-04-22 18:19:52 +02:00
adf50862f0 Merge branch 'develop' 2021-04-22 12:29:10 +02:00
e8ab4527f7 Merge branch 'develop' 2021-04-21 13:58:23 +02:00
55d2040dc3 Merge branch 'develop' 2021-03-30 12:12:54 +02:00
0644bb7bda Merge branch 'develop' 2021-02-18 11:48:56 +01:00
ea75813b98 Merge branch 'develop' 2021-01-26 17:57:26 +01:00
a6e822ec64 Merge branch 'develop' 2021-01-25 17:54:36 +01:00
0a11792557 Merge branch 'develop' 2021-01-25 17:29:26 +01:00
1d4ea1720b Merge branch 'develop' 2021-01-25 12:53:50 +01:00
9a862bc334 Merge branch 'develop' 2021-01-25 12:48:56 +01:00
1f9fcbec58 Merge branch 'develop' 2021-01-25 10:56:36 +01:00
57 changed files with 3586 additions and 4350 deletions

View File

@ -37,10 +37,10 @@
mysql: { mysql: {
connectionLimit: 10, connectionLimit: 10,
host: "127.0.0.1", host: "127.0.0.1",
user: "swaf", user: "root",
password: "swaf", password: "",
database: "swaf", database: "swaf",
create_database_automatically: true, create_database_automatically: false,
}, },
redis: { redis: {
host: "127.0.0.1", host: "127.0.0.1",

View File

@ -16,9 +16,6 @@
secure: true, secure: true,
allow_invalid_tls: false, allow_invalid_tls: false,
}, },
mysql: {
create_database_automatically: false,
},
session: { session: {
cookie: { cookie: {
secure: true, secure: true,

View File

@ -3,6 +3,9 @@
approval_mode: true, approval_mode: true,
}, },
mysql: { mysql: {
host: "127.0.0.1",
user: "root",
password: "",
database: "swaf_test", database: "swaf_test",
create_database_automatically: true, create_database_automatically: true,
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "swaf", "name": "swaf",
"version": "0.25.1", "version": "0.24.9",
"description": "Structure Web Application Framework.", "description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf", "repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
@ -25,9 +25,9 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/eslint-config": "sveltejs/eslint-config", "@sveltejs/eslint-config": "sveltejs/eslint-config",
"@tsconfig/svelte": "^3.0.0", "@tsconfig/svelte": "^2.0.1",
"@types/compression": "^1.7.0", "@types/compression": "^1.7.0",
"@types/config": "^0.0.41", "@types/config": "^0.0.40",
"@types/connect-flash": "^0.0.37", "@types/connect-flash": "^0.0.37",
"@types/cookie": "^0.4.0", "@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2", "@types/cookie-parser": "^1.4.2",
@ -38,10 +38,11 @@
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",
"@types/mjml": "^4.0.4", "@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10", "@types/mysql": "^2.15.10",
"@types/node": "^20.15.0", "@types/node": "^16.11.10",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3", "@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1", "@types/on-finished": "^2.3.1",
"@types/redis": "^2.8.18",
"@types/require-from-string": "^1.2.0", "@types/require-from-string": "^1.2.0",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0", "@types/uuid": "^8.0.0",
@ -49,7 +50,7 @@
"@typescript-eslint/eslint-plugin": "^5.3.0", "@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0", "@typescript-eslint/parser": "^5.3.0",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"concurrently": "^7.0.0", "concurrently": "^6.0.0",
"eslint": "^8.2.0", "eslint": "^8.2.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
@ -58,7 +59,7 @@
"jest": "^27.3.1", "jest": "^27.3.1",
"jest-resolve": "^27.3.1", "jest-resolve": "^27.3.1",
"jest-ts-webcompat-resolver": "^1.0.0", "jest-ts-webcompat-resolver": "^1.0.0",
"maildev": "^2.1.0", "maildev": "^1.1.0",
"node-fetch": "^3.0.0", "node-fetch": "^3.0.0",
"nodemon": "^2.0.6", "nodemon": "^2.0.6",
"sass": "^1.32.12", "sass": "^1.32.12",
@ -82,7 +83,7 @@
"express-session": "^1.17.1", "express-session": "^1.17.1",
"formidable": "^2.0.1", "formidable": "^2.0.1",
"geoip-lite": "^1.4.2", "geoip-lite": "^1.4.2",
"lucide": "^0.17.7", "lucide": "^0.16.17",
"mjml": "^4.6.2", "mjml": "^4.6.2",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
@ -90,7 +91,7 @@
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"nunjucks": "^3.2.1", "nunjucks": "^3.2.1",
"on-finished": "^2.3.0", "on-finished": "^2.3.0",
"redis": "^4.0.4", "redis": "^3.0.2",
"require-from-string": "^2.0.2", "require-from-string": "^2.0.2",
"rollup": "^2.42.3", "rollup": "^2.42.3",
"rollup-plugin-css-only": "^3.1.0", "rollup-plugin-css-only": "^3.1.0",

View File

@ -18,7 +18,7 @@ import Extendable, {MissingComponentError} from "./Extendable.js";
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js"; import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
import {logger, loggingContextMiddleware} from "./Logger.js"; import {logger, loggingContextMiddleware} from "./Logger.js";
import SecurityError from "./SecurityError.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 WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError; import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js"; 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) => { app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err); 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 // Transform single validation errors into a validation bag for convenience
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
const bag = new ValidationBag(); const bag = new ValidationBag();
@ -331,8 +326,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
for (const file of fs.readdirSync(configDir)) { for (const file of fs.readdirSync(configDir)) {
const fullPath = path.resolve(configDir, file); const fullPath = path.resolve(configDir, file);
const stats = fs.lstatSync(fullPath); const stats = fs.lstatSync(fullPath);
if (stats.uid !== process.getuid?.()) if (stats.uid !== process.getuid())
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid?.()}).`); throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid()}).`);
const mode = (stats.mode & parseInt('777', 8)).toString(8); const mode = (stats.mode & parseInt('777', 8)).toString(8);
if (mode !== '400') if (mode !== '400')

View File

@ -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>;
}

View File

@ -4,12 +4,12 @@ import Application from "./Application.js";
import AccountController from "./auth/AccountController.js"; import AccountController from "./auth/AccountController.js";
import AuthComponent from "./auth/AuthComponent.js"; import AuthComponent from "./auth/AuthComponent.js";
import AuthController from "./auth/AuthController.js"; import AuthController from "./auth/AuthController.js";
import AddUsedToMagicLinksMigration from "./auth/magic_link/migrations/AddUsedToMagicLinksMigration.js"; import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration.js";
import CreateMagicLinksTableMigration from "./auth/magic_link/migrations/CreateMagicLinksTableMigration.js"; import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration.js";
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js"; import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
import MagicLinkController from "./auth/magic_link/MagicLinkController.js"; import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.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 AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js"; import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.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 MailController from "./mail/MailController.js";
import {MAGIC_LINK_MAIL} from "./Mails.js"; import {MAGIC_LINK_MAIL} from "./Mails.js";
import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js"; import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js";
import IncreaseMagicLinkTokenLengthMigration from "./auth/magic_link/migrations/IncreaseMagicLinkTokenLengthMigration.js";
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
@ -53,7 +52,6 @@ export const MIGRATIONS = [
MakeMagicLinksSessionNotUniqueMigration, MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration, AddUsedToMagicLinksMigration,
AddNameChangedAtToUsersMigration, AddNameChangedAtToUsersMigration,
IncreaseMagicLinkTokenLengthMigration,
]; ];
export default class TestApp extends Application { export default class TestApp extends Application {

View File

@ -1,3 +1,4 @@
import {Session} from "express-session";
import {IncomingMessage} from "http"; import {IncomingMessage} from "http";
import WebSocket from "ws"; import WebSocket from "ws";
@ -19,5 +20,6 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract handle( public abstract handle(
socket: WebSocket, socket: WebSocket,
request: IncomingMessage, request: IncomingMessage,
session: Session | null,
): Promise<void>; ): Promise<void>;
} }

View File

@ -155,7 +155,7 @@ h1 {
a { a {
text-decoration: none; text-decoration: none;
.icon.lucide-external-link { .icon.lucide-external-link { //todo add js
--icon-size: 16px; --icon-size: 16px;
margin-left: 4px; margin-left: 4px;
margin-top: -3px; margin-top: -3px;
@ -166,13 +166,6 @@ ul {
list-style-type: '- '; list-style-type: '- ';
} }
hr {
margin: 0;
border: 0;
border-top: 1px solid var(--on-background);
opacity: 0.2;
}
.primary, .bold { .primary, .bold {
--color: var(--primary-on-background); --color: var(--primary-on-background);
--background-color: var(--background); --background-color: var(--background);
@ -256,7 +249,7 @@ button, .button {
margin-left: 8px; margin-left: 8px;
} }
&:hover::after:not([disabled]) { &:hover::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -267,10 +260,4 @@ button, .button {
background-color: var(--on-background); background-color: var(--on-background);
opacity: 0.2; opacity: 0.2;
} }
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
} }

View File

@ -23,46 +23,6 @@
thead tr:hover { thead tr:hover {
background-color: transparent; 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 { .data-table-container {

View File

@ -17,7 +17,6 @@ export default class WebsocketClient {
const websocket = new WebSocket(this.websocketUrl); const websocket = new WebSocket(this.websocketUrl);
websocket.onopen = () => { websocket.onopen = () => {
console.debug('Websocket connected'); console.debug('Websocket connected');
websocket.send(document.cookie);
}; };
websocket.onmessage = (e) => { websocket.onmessage = (e) => {
this.listener(websocket, e); this.listener(websocket, e);

View File

@ -1,7 +1,5 @@
import {createIcons, icons} from "lucide"; import {createIcons, icons} from "lucide";
import {toLucideIconsPascalCase} from "../../common/StringUtils.js";
let hasAlreadyReplacedIcons = false; let hasAlreadyReplacedIcons = false;
export function replaceIcons(once: boolean): void { export function replaceIcons(once: boolean): void {
@ -11,7 +9,3 @@ export function replaceIcons(once: boolean): void {
hasAlreadyReplacedIcons = true; hasAlreadyReplacedIcons = true;
} }
} }
export function isLucideIcon(iconName: string): boolean {
return Object.keys(icons).indexOf(toLucideIconsPascalCase(iconName)) >= 0;
}

View File

@ -15,87 +15,81 @@
</script> </script>
<BaseTemplate title="Account" description="Manage your account settings and data."> <BaseTemplate title="Account" description="Manage your account settings and data.">
<section class="panel"> <div class="container">
<h2> <div class="panel">
<Icon name="user"/> <h2><Icon name="user"/> Personal information</h2>
Personal information
</h2>
{#if $locals.display_email_warning && $locals.emails.length <= 0} {#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."/> <Message type="warning" content="To avoid losing access to your account, please add an email address."/>
{/if} {/if}
{#each personalInfoFields as field} {#each personalInfoFields as field}
<p>{field.name}: {field.value}</p> <p>{field.name}: {field.value}</p>
{/each} {/each}
{#if mainEmail} {#if mainEmail}
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p> <p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
{/if} {/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>
</div> </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" <Form action={route('add-email')} class="sub-panel"
submitIcon="plus" submitText="Add email address"> submitIcon="plus" submitText="Add email address">
<h3>Add an email address:</h3> <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" hint="An email address we can use to identify you in case you lose access to your account"
required/> required/>
</Form> </Form>
{/if} {/if}
</section> </section>
</div>
</BaseTemplate> </BaseTemplate>

View File

@ -6,8 +6,8 @@
import Icon from "../utils/Icon.svelte"; import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing"; import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password'; let registerUsingMagicLink = $locals.previousFormData?.['auth_method'] !== 'password';
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password']; let loginUsingMagicLink = true;
let queryStr = ''; let queryStr = '';
let previousUrl: string = $locals.previousUrl as string | undefined; let previousUrl: string = $locals.previousUrl as string | undefined;
@ -22,72 +22,49 @@
description="Join {$locals.app.name} and share your files!" description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration"> h1="Authentication and registration">
{#if hasRoute('login')} <div class="container">
{#if hasRoute('login')}
<section class="panel"> <section class="panel">
<h2> <h2><Icon name="log-in"/> Log in</h2>
<Icon name="log-in"/>
Log in
</h2>
<Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in"> <Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
<Field type="text" name="identifier" value={$locals.query?.identifier} icon="at-sign" <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.' : ''} 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/> placeholder="Your email address or username" required/>
{#if $locals.hasPassword} {#if !loginUsingMagicLink}
{#if loginUsingMagicLink} <Field type="password" name="password" placeholder="Your password" icon="key" required/>
<button on:click={() => loginUsingMagicLink=false} type="button"> <button on:click={() => loginUsingMagicLink=true} type="button"><Icon name="mail"/> Use magic link</button>
<Icon name="key"/> {:else}
Use password <button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button>
</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} {/if}
<Field type="checkbox" name="persist_session" icon="clock" <Field type="checkbox" name="persist_session" icon="clock" placeholder="Stay logged in on this computer."/>
placeholder="Stay logged in on this computer."/>
</Form> </Form>
</section> </section>
{/if} {/if}
{#if hasRoute('register')} {#if hasRoute('register')}
<section class="panel"> <section class="panel">
<h2> <h2><Icon name="user-plus"/> Register</h2>
<Icon name="user-plus"/>
Register
</h2>
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check"> <Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/> <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" <Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username" placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/> pattern="[0-9a-z_-]+" required/>
{/if} {/if}
{#if registerUsingMagicLink || !$locals.canRegisterWithPassword} {#if registerUsingMagicLink}
<Field type="email" name="identifier" icon="at-sign" placeholder="Your email address" <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." hint="You will receive a magic link in your mailbox. Click on the link from any device to register here."
required/> required/>
{#if $locals.canRegisterWithPassword} <button on:click={() => registerUsingMagicLink=false} type="button"><Icon name="key"/>Use password</button>
<button on:click={() => registerUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{/if}
{:else} {:else}
<Field type="password" name="password" icon="key" placeholder="Choose a password" required/> <Field type="password" name="password" icon="key" placeholder="Choose a password" required/>
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password" <Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password" required/>
required/> <button on:click={() => registerUsingMagicLink=true} type="button"><Icon name="at-sign"/>Use email address instead</button>
<button on:click={() => registerUsingMagicLink=true} type="button">
<Icon name="at-sign"/>
Use email address instead
</button>
{/if} {/if}
<Field type="checkbox" name="terms" icon="file-text" required> <Field type="checkbox" name="terms" icon="file-text" required>
@ -95,5 +72,6 @@
</Field> </Field>
</Form> </Form>
</section> </section>
{/if} {/if}
</div>
</BaseTemplate> </BaseTemplate>

View File

@ -8,24 +8,26 @@
</script> </script>
<BaseTemplate title="{$locals.app.name} backend" h1={false}> <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"> <div class="panel">
<nav> <nav>
<ul> <ul>
{#each menu as element} {#each menu as element}
<li> <li>
<a href={element.link}> <a href={element.link}>
{#if element.display_icon !== null} {#if element.display_icon !== null}
<Icon name={element.display_icon}/> <Icon name={element.display_icon}/>
{/if} {/if}
{element.display_string} {element.display_string}
</a> </a>
</li> </li>
{/each} {/each}
</ul> </ul>
</nav> </nav>
</div>
</div> </div>
</BaseTemplate> </BaseTemplate>

View File

@ -2,9 +2,8 @@
import Icon from "../utils/Icon.svelte"; import Icon from "../utils/Icon.svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
export let title: string | undefined = undefined; export let title: string;
export let content: string; export let content: string;
export let buttonMode: boolean = false;
let contentNode: HTMLElement; let contentNode: HTMLElement;
let copiedOverlay: HTMLElement; let copiedOverlay: HTMLElement;
@ -69,55 +68,28 @@
margin: 8px; 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> </style>
{#if buttonMode} <div class="copyable-text panel">
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div> <div class="title">{title}</div>
<button class="bold button-mode-button" on:click={copy} title="{content}"> <div class="content" bind:this={contentNode} on:click={selectAll}>{content}</div>
<Icon name="copy"/> <button class="bold copy-button" on:click={copy}><Icon name="copy"/></button>
{#if showCopiedOverlay} {#if showCopiedOverlay}
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}><Icon name="check"/></div> <div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
{/if} {/if}
</button> </div>
{: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}

View File

@ -3,13 +3,6 @@
import Message from "./Message.svelte"; import Message from "./Message.svelte";
export let flashed = $locals.flash; export let flashed = $locals.flash;
const displayedCategories = [
'success',
'info',
'warning',
'error',
'error-alert',
];
</script> </script>
<style lang="scss"> <style lang="scss">
@ -20,7 +13,7 @@
<div class="messages"> <div class="messages">
{#if flashed} {#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} {#each bag as content}
<Message type={key} content={content}/> <Message type={key} content={content}/>
{/each} {/each}

View File

@ -67,19 +67,16 @@
} }
ul { ul {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
align-items: center;
@include large-ge { @include large-ge {
display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
} }
} }
} }

View File

@ -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>

View File

@ -6,37 +6,30 @@
export let icon; export let icon;
export let text; export let text;
export let action = false; export let action = false;
export let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script> </script>
<style lang="scss"> <style lang="scss">
@import "../../scss/helpers"; @import "../../scss/helpers";
li { li {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: center;
gap: 8px;
line-height: 1; line-height: 1;
@mixin aHover { @include medium-le {
background-color: rgba(0, 0, 0, 0.07); &:not(:first-child) {
margin-top: 8px;
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
} }
} }
@include large-ge {
&:not(:first-child) {
margin-left: 8px;
}
}
a { a {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -45,23 +38,17 @@
height: auto; height: auto;
padding: 8px; padding: 8px;
white-space: nowrap;
border-radius: 3px; border-radius: 3px;
text-transform: uppercase; &:hover {
background-color: rgba(0, 0, 0, 0.07);
@include medium-le { @include darkMode {
&:hover { background-color: rgba(255, 255, 255, 0.07);
@include aHover;
} }
} }
}
@include large-ge { text-transform: uppercase;
&:hover > a {
@include aHover;
}
} }
:global(form) { :global(form) {
@ -82,7 +69,7 @@
} }
</style> </style>
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}> <li>
{#if action} {#if action}
<Form action={href} submitIcon={icon} submitText={text}/> <Form action={href} submitIcon={icon} submitText={text}/>
{:else} {:else}
@ -90,5 +77,4 @@
<Icon name={icon}/> <Icon name={icon}/>
<span class="tip">{text}</span></a> <span class="tip">{text}</span></a>
{/if} {/if}
<slot/>
</li> </li>

View File

@ -18,16 +18,16 @@
<p>Welcome to {$locals.app.name}!</p> <p>Welcome to {$locals.app.name}!</p>
{#if hasAnyRoute('tests', 'design')} {#if hasAnyRoute('tests', 'design')}
<nav> <nav>
<ul> <ul>
{#if hasRoute('tests')} {#if hasRoute('tests')}
<li><a href={route('tests')}>Frontend tests</a></li> <li><a href={route('tests')}>Frontend tests</a></li>
{/if} {/if}
{#if hasRoute('design')} {#if hasRoute('design')}
<li><a href={route('design')}>Design test</a></li> <li><a href={route('design')}>Design test</a></li>
{/if} {/if}
</ul> </ul>
</nav> </nav>
{/if} {/if}
</div> </div>
</BaseTemplate> </BaseTemplate>

View File

@ -8,12 +8,14 @@
</script> </script>
<BaseTemplate title="{$locals.app.name} {h1}" {h1}> <BaseTemplate title="{$locals.app.name} {h1}" {h1}>
<div class="panel"> <div class="container">
{#if $locals.err} <div class="panel">
<Message type="error" content={$locals.err}/> {#if $locals.err}
{:else} <Message type="error" content={$locals.err}/>
<Message type="success" content="Success!"/> {:else}
<p>You can now close this page.</p> <Message type="success" content="Success!"/>
{/if} <p>You can now close this page.</p>
{/if}
</div>
</div> </div>
</BaseTemplate> </BaseTemplate>

View File

@ -38,12 +38,14 @@
</script> </script>
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby"> <BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<div class="panel"> <div class="container">
<Message type="success" sticky <div class="panel">
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/> <Message type="success" sticky
<Message type="info" discreet sticky raw content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
content={`This link will be valid for ${countdown} and can only be used once.`}/> <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> </div>
</BaseTemplate> </BaseTemplate>

View File

@ -3,12 +3,10 @@
import BaseFooter from "./base/BaseFooter.svelte"; import BaseFooter from "./base/BaseFooter.svelte";
import BaseHeader from "./base/BaseHeader.svelte"; import BaseHeader from "./base/BaseHeader.svelte";
import CommonScripts from "./CommonScripts.svelte"; import CommonScripts from "./CommonScripts.svelte";
import {locals} from '../../ts/stores.js';
export let title: string; export let title: string;
export let h1: string = title; export let h1: string = title;
export let description: string; export let description: string;
export let previewImageUrl: string | undefined = undefined;
export let refresh_after: number | undefined = undefined; export let refresh_after: number | undefined = undefined;
export let noHeader: boolean = false; export let noHeader: boolean = false;
export let noH1: boolean = false; export let noH1: boolean = false;
@ -39,22 +37,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title || 'Undefined title'}</title> <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} {#if description}
<meta name="description" content={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} {/if}
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png"> <link rel="shortcut icon" type="image/png" href="/img/logox1024.png">

View File

@ -135,7 +135,7 @@
background-image: url(../../img/logo.svg); background-image: url(../../img/logo.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
background-size: contain; background-size: 64px;
opacity: 0.075; opacity: 0.075;
filter: contrast(0); filter: contrast(0);

View File

@ -2,11 +2,8 @@
import {locals} from "../../../ts/stores.js"; import {locals} from "../../../ts/stores.js";
import NavMenuItem from "../../components/NavMenuItem.svelte"; import NavMenuItem from "../../components/NavMenuItem.svelte";
import {hasRoute, route} from "../../../../common/Routing"; import {hasRoute, route} from "../../../../common/Routing";
import NavMenuDropdown from "../../components/NavMenuDropdown.svelte";
import BaseNavMenuAuthAccountDropdownAdditionalLinks from "./BaseNavMenuAuthAccountDropdownAdditionalLinks.svelte";
export let noLoginLink = false; export let noLoginLink = false;
let accountItemHovered;
</script> </script>
{#if hasRoute('auth')} {#if hasRoute('auth')}
@ -15,12 +12,8 @@
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/> <NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if} {/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'} bind:hovered={accountItemHovered}> <NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
<NavMenuDropdown bind:open={accountItemHovered}> <NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
<BaseNavMenuAuthAccountDropdownAdditionalLinks/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
</NavMenuDropdown>
</NavMenuItem>
{:else if !noLoginLink} {:else if !noLoginLink}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/> <NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if} {/if}

View File

@ -8,19 +8,19 @@
export let type: string; export let type: string;
export let name: 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 value: FieldValue | undefined = undefined;
export let initialValue: FieldValue | undefined = undefined; export let initialValue: FieldValue | undefined = undefined;
export let placeholder: string | undefined = undefined; export let placeholder: string | undefined = undefined;
export let hint: string | undefined = undefined; export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined; export let extraData: string[] | undefined = undefined;
export let icon: 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 formId = getContext('formId');
const fieldId = `${formId}-${name}-field`; 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]; let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData); if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
@ -45,13 +45,9 @@
} }
} }
function focusInput(e) { function focusInput() {
if (input) { if (input) {
if (input.type === 'checkbox') { if (['file', 'checkbox', 'color'].indexOf(input.type) >= 0) {
if (e.target !== label && e.target !== input) {
input.click();
}
} else if (['file', 'color'].indexOf(input.type) >= 0) {
input.click(); input.click();
} else { } else {
input.focus(); input.focus();
@ -62,37 +58,31 @@
} }
function handleInput() { function handleInput() {
switch (this.type) { // in here, you can switch on type and implement
case 'number': // whatever behaviour you need
case 'range': value = type.match(/^(number|range)$/)
value = +this.value; ? +this.value
break; : this.value;
case 'file':
handleFileInput(); if (this.type === 'file') {
break; handleFileInput();
case 'datetime-local': }
value = dateToIsoString(new Date(this.value));
break; if (this.type === 'datetime-local') {
case 'checkbox': value = dateToIsoString(new Date(this.value));
value = !!this.checked;
break;
default:
value = this.value;
break;
} }
} }
let input: HTMLInputElement; let input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() { function chooseFile() {
input.click(); input.click();
} }
export let fileList: FileList | undefined = undefined; let files: FileList | undefined;
function handleFileInput() { function handleFileInput() {
fileList = input.files; files = input.files;
} }
let focused = false; let focused = false;
@ -108,7 +98,6 @@
.control { .control {
position: relative; position: relative;
z-index: 0;
display: flex; display: flex;
align-items: start; align-items: start;
flex-direction: row; flex-direction: row;
@ -434,8 +423,7 @@
on:focusout={() => focused = false}></textarea> on:focusout={() => focused = false}></textarea>
</div> </div>
{:else if type === 'checkbox'} {:else if type === 'checkbox'}
<input {type} {name} id={fieldId} checked={!!value} {...$$restProps} bind:this={input} <input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps} bind:this={input}>
on:change={handleInput}>
{:else if type === 'datetime-local'} {:else if type === 'datetime-local'}
<input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}> <input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}>
<input type="hidden" {name} {value}> <input type="hidden" {name} {value}>
@ -446,15 +434,15 @@
<div class="sections"> <div class="sections">
{#if type !== 'duration'} {#if type !== 'duration'}
<label for={fieldId} bind:this={label}>{@html placeholder || ''} <label for={fieldId}>{@html placeholder || ''}
<slot/> <slot/>
</label> </label>
{/if} {/if}
{#if type === 'file'} {#if type === 'file'}
{#if fileList} {#if files}
<div class="files"> <div class="files">
{#each fileList as file} {#each files as file}
<div class="file"> <div class="file">
<div class="name" title="Type: {file.type}"> <div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name} <Icon name="file"/> {file.name}

View File

@ -11,7 +11,6 @@
export let submitText: string; export let submitText: string;
export let submitIcon: string; export let submitIcon: string;
export let submitClass: string = undefined; export let submitClass: string = undefined;
export let submitDisabled: boolean = false;
export let isBoldSubmit: boolean = true; export let isBoldSubmit: boolean = true;
export let resetButton: boolean = false; export let resetButton: boolean = false;
export let confirm: string = undefined; export let confirm: string = undefined;
@ -20,11 +19,11 @@
const formId = nextAvailableFormId++; const formId = nextAvailableFormId++;
setContext('formId', formId); setContext('formId', formId);
export let onSubmit = function(e) { function handleSubmit(e) {
if (submitDisabled || confirm && !window.confirm(confirm)) { if (confirm && !window.confirm(confirm)) {
e.preventDefault(); e.preventDefault();
} }
}; }
</script> </script>
<style lang="scss"> <style lang="scss">
@ -39,7 +38,7 @@
} }
</style> </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/> <CsrfTokenField/>
<slot/> <slot/>
<div class="form-controls"> <div class="form-controls">
@ -47,7 +46,7 @@
<button type="reset"><Icon name="trash"/>Reset</button> <button type="reset"><Icon name="trash"/>Reset</button>
{/if} {/if}
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}> <button type="submit" class={submitClass} class:bold={isBoldSubmit}>
{#if submitIcon} {#if submitIcon}
<Icon name={submitIcon}/> <Icon name={submitIcon}/>
{/if} {/if}

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {replaceIcons, isLucideIcon} from "../../ts/icons.js"; import {replaceIcons} from "../../ts/icons.js";
import {afterUpdate, onMount} from "svelte"; import {afterUpdate, onMount} from "svelte";
export let name: string; export let name: string;
@ -39,9 +39,9 @@
</style> </style>
{#if name} {#if name}
{#if isLucideIcon(name) >= 0 } {#if name.startsWith('fa') }
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
{:else}
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i> <i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
{:else}
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
{/if} {/if}
{/if} {/if}

View File

@ -12,15 +12,6 @@ import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js"; import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AuthController extends Controller { 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 { public getRoutesPrefix(): string {
return '/auth'; return '/auth';
} }
@ -44,12 +35,10 @@ export default class AuthController extends Controller {
const userModelFactory = ModelFactory.get(User); const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent); const hasUsername = userModelFactory.hasComponent(UserNameComponent);
const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
res.formatViewData('auth/auth', { res.formatViewData('auth/auth', {
auth_methods: authGuard.getAuthMethodNames(), auth_methods: authGuard.getAuthMethodNames(),
hasUsername: hasUsername, has_username: hasUsername,
hasPassword: hasPassword, register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
canRegisterWithPassword: hasUsername && hasPassword,
}); });
} }

View File

@ -160,9 +160,6 @@ export default class AuthGuard {
throw new PendingApprovalAuthError(); throw new PendingApprovalAuthError();
} }
// Mark auth proof as used
await proof.use?.();
// Login // Login
session.isAuthenticated = true; session.isAuthenticated = true;
session.persistent = persistSession; session.persistent = persistSession;

View File

@ -38,10 +38,4 @@ export default interface AuthProof<R> {
* instance. * instance.
*/ */
revoke(): Promise<void>; 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>;
} }

View File

@ -1,4 +1,4 @@
import Migration from "../../../db/Migration.js"; import Migration from "../../db/Migration.js";
export default class AddUsedToMagicLinksMigration extends Migration { export default class AddUsedToMagicLinksMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {

View File

@ -1,6 +1,6 @@
import Migration from "../../../db/Migration.js"; import Migration from "../../db/Migration.js";
import ModelFactory from "../../../db/ModelFactory.js"; import ModelFactory from "../../db/ModelFactory.js";
import MagicLink from "../../models/MagicLink.js"; import MagicLink from "../models/MagicLink.js";
export default class CreateMagicLinksTableMigration extends Migration { export default class CreateMagicLinksTableMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {

View File

@ -83,7 +83,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
} }
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> { 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; const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
if (isRegistration) { if (isRegistration) {

View File

@ -12,7 +12,6 @@ import Mail from "../../mail/Mail.js";
import MailTemplate from "../../mail/MailTemplate.js"; import MailTemplate from "../../mail/MailTemplate.js";
import Throttler from "../../Throttler.js"; import Throttler from "../../Throttler.js";
import AuthComponent, {AuthMiddleware} from "../AuthComponent.js"; import AuthComponent, {AuthMiddleware} from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import MagicLink from "../models/MagicLink.js"; import MagicLink from "../models/MagicLink.js";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js";
@ -142,7 +141,7 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
if (await validLink.isAuthorized()) { if (await validLink.isAuthorized()) {
validLink.useLink(); validLink.use();
await validLink.save(); await validLink.save();
await this.performAction(validLink, req, res); await this.performAction(validLink, req, res);
return; return;
@ -199,7 +198,8 @@ export default class MagicLinkController<A extends Application> extends Controll
if (!res.headersSent && user) { if (!res.headersSent && user) {
// Auth success // 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')); res.redirect(req.getIntendedUrl() || route('home'));
} }
break; break;

View File

@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
import WebSocket from "ws"; import WebSocket from "ws";
import Application from "../../Application.js"; import Application from "../../Application.js";
import SessionWebSocketListener from "../../SessionWebSocketListener.js"; import WebSocketListener from "../../WebSocketListener.js";
import MagicLink from "../models/MagicLink.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 } = {}; private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
public refreshMagicLink(sessionId: string): void { 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 // Refuse any incoming data
socket.on('message', () => { socket.on('message', () => {
socket.close(1003); socket.close(1003);
@ -31,22 +37,19 @@ export default class MagicLinkWebSocketListener<A extends Application> extends S
// Refresh if immediately applicable // Refresh if immediately applicable
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) { if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
socket.send('refresh'); socket.send('refresh');
const reason = magicLink ? socket.close(1000);
'Magic link state changed.' :
'Magic link not found for session ' + session.id;
socket.close(1000, reason);
return; return;
} }
const validityTimeout = setTimeout(() => { const validityTimeout = setTimeout(() => {
socket.send('refresh'); socket.send('refresh');
socket.close(1000, 'Timed out'); socket.close(1000);
}, magicLink.getExpirationDate().getTime() - new Date().getTime()); }, magicLink.getExpirationDate().getTime() - new Date().getTime());
const f = () => { const f = () => {
clearTimeout(validityTimeout); clearTimeout(validityTimeout);
socket.send('refresh'); socket.send('refresh');
socket.close(1000, 'Closed by server'); socket.close(1000);
}; };
socket.on('close', () => { socket.on('close', () => {

View File

@ -1,4 +1,4 @@
import Migration from "../../../db/Migration.js"; import Migration from "../../db/Migration.js";
export default class MakeMagicLinksSessionNotUniqueMigration extends Migration { export default class MakeMagicLinksSessionNotUniqueMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {

View File

@ -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)`);
}
}

View File

@ -26,7 +26,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
protected init(): void { protected init(): void {
this.setValidation('session_id').defined().length(32); this.setValidation('session_id').defined().length(32);
this.setValidation('email').defined().regexp(EMAIL_REGEX); 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('action_type').defined().maxLength(64);
this.setValidation('original_url').defined().maxLength(1745); this.setValidation('original_url').defined().maxLength(1745);
this.setValidation('authorized').defined(); this.setValidation('authorized').defined();
@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
return this.used; return this.used;
} }
public useLink(): void { public use(): void {
this.used = true; this.used = true;
} }

View File

@ -8,7 +8,6 @@ import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
import {ServerError} from "../../HttpError.js"; import {ServerError} from "../../HttpError.js";
import Throttler from "../../Throttler.js"; import Throttler from "../../Throttler.js";
import AuthComponent from "../AuthComponent.js"; import AuthComponent from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import AuthMethod from "../AuthMethod.js"; import AuthMethod from "../AuthMethod.js";
import User from "../models/User.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 Throttler.throttle('login_failed_attempts_user', 3, 3 * 60 * 1000, // 3min
user.getOrFail('id').toString(), 1000, 60 * 1000); // 1min user.getOrFail('id').toString(), 1000, 60 * 1000); // 1min
Throttler.throttle('login_failed_attempts_ip', 50, 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) { if (e instanceof PendingApprovalAuthError) {
req.flash('error', 'Your account is still being reviewed.'); 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')); res.redirect(req.getIntendedUrl() || route('home'));
} }
@ -98,7 +97,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
if (!ModelFactory.get(User).hasComponent(UserNameComponent)) if (!ModelFactory.get(User).hasComponent(UserNameComponent))
throw new ServerError('Cannot register with password without 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; req.body.identifier = identifier;
@ -137,9 +136,8 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
} }
const user = await passwordAuthProof.getResource(); 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')); res.redirect(req.getIntendedUrl() || route('home'));
} }

View File

@ -1,2 +0,0 @@
export const toLucideIconsPascalCase = (string: string): string =>
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());

View File

@ -67,7 +67,7 @@ export class Time {
public static humanizeDuration( public static humanizeDuration(
duration: number, duration: number,
short: boolean = false, short: boolean = false,
skipNumberTextPartWhenSingular: boolean = false, skipOneUnitNumber: boolean = false,
units: TimeUnit[] = [ units: TimeUnit[] = [
this.UNITS.SECOND, this.UNITS.SECOND,
this.UNITS.MINUTE, this.UNITS.MINUTE,
@ -77,13 +77,13 @@ export class Time {
this.UNITS.YEAR, this.UNITS.YEAR,
], ],
): string { ): string {
for (const unit of units.reverse()) { for (let i = units.length - 1; i > 0; i--) {
if (duration >= unit.milliseconds) { if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) {
const amount = Math.floor(duration / unit.milliseconds); const amount = Math.floor(duration / units[i - 1].milliseconds);
const unitTextPart = short ? const unit = short ?
unit.shortName : units[i - 1].shortName :
' ' + unit.longName + (amount > 1 ? 's' : ''); ' ' + units[i - 1].longName + (amount > 1 ? 's' : '');
return (amount > 1 || !skipNumberTextPartWhenSingular ? amount : '') + unitTextPart; return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit;
} }
} }

View File

@ -5,6 +5,19 @@ import ApplicationComponent from "../ApplicationComponent.js";
export default class FormHelperComponent extends ApplicationComponent { export default class FormHelperComponent extends ApplicationComponent {
public async initRoutes(router: Router): Promise<void> { 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) => { router.use((req, res, next) => {
if (['GET', 'POST'].find(m => m === req.method)) { if (['GET', 'POST'].find(m => m === req.method)) {
if (typeof req.body === 'object' && Object.keys(req.body).length > 0) { if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {

View File

@ -1,7 +1,7 @@
import config from "config"; import config from "config";
import {Express} from "express"; import {Express} from "express";
import session, {Store} from "express-session"; import session, {Store} from "express-session";
import {createClient, RedisClientType} from "redis"; import redis, {RedisClient} from "redis";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import CacheProvider from "../CacheProvider.js"; import CacheProvider from "../CacheProvider.js";
@ -9,30 +9,22 @@ import {logger} from "../Logger.js";
export default class RedisComponent extends ApplicationComponent implements CacheProvider { export default class RedisComponent extends ApplicationComponent implements CacheProvider {
private readonly prefix: string = config.get('redis.prefix'); private readonly prefix: string = config.get('redis.prefix');
private redisClient?: RedisClientType; private redisClient?: RedisClient;
private store: Store = new RedisStore(this); private store: Store = new RedisStore(this);
public async start(_app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
const redisUrl = `redis://${config.get('redis.host')}:${config.get('redis.port')}`; this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), {
this.redisClient = createClient({
url: redisUrl,
password: config.has('redis.password') ? config.get<string>('redis.password') : undefined, password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
}); });
this.redisClient.on('error', (err: Error) => { this.redisClient.on('error', (err: Error) => {
logger.error(err, 'An error occurred with redis.'); logger.error(err, 'An error occurred with redis.');
}); });
await this.redisClient.connect();
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
const redisClient = this.redisClient; const redisClient = this.redisClient;
if (redisClient) { if (redisClient) {
await this.close('Redis connection', callback => { await this.close('Redis connection', callback => redisClient.quit(callback));
redisClient.quit()
.then(() => callback())
.catch(callback);
});
} }
} }
@ -41,14 +33,24 @@ export default class RedisComponent extends ApplicationComponent implements Cach
} }
public isReady(): boolean { 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> { public async get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T> {
if (!this.redisClient) { return await new Promise<T>((resolve, reject) => {
throw new Error(`Redis client was not initialized.`); if (!this.redisClient) {
} reject(`Redis client was not initialized.`);
return (await this.redisClient.get(this.prefix + key)|| defaultValue || undefined) as T; 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> { 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> { public async forget(key: string): Promise<void> {
if (!this.redisClient) { return await new Promise<void>((resolve, reject) => {
throw new Error(`Redis client was not initialized.`); 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> { public async remember(key: string, value: string, ttl: number): Promise<void> {
if (!this.redisClient) { return await new Promise<void>((resolve, reject) => {
throw new Error(`Redis client was not initialized.`); 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> { public async persist(key: string, ttl: number): Promise<void> {
if (!this.redisClient) { return await new Promise<void>((resolve, reject) => {
throw new Error(`Redis client was not initialized.`); 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();
});
});
} }
} }

View File

@ -29,9 +29,8 @@ export default class SessionComponent extends ApplicationComponent {
store: this.storeComponent.getStore(), store: this.storeComponent.getStore(),
resave: false, resave: false,
cookie: { cookie: {
httpOnly: false, httpOnly: true,
secure: config.get('session.cookie.secure'), secure: config.get('session.cookie.secure'),
sameSite: 'strict',
}, },
rolling: true, rolling: true,
})); }));
@ -62,7 +61,13 @@ export default class SessionComponent extends ApplicationComponent {
// Views flash function // Views flash function
res.setLazyLocal('flash', () => { 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(); next();
}); });

View File

@ -1,5 +1,8 @@
import config from "config"; 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 {WebSocketServer} from "ws";
import Application from "../Application.js"; import Application from "../Application.js";
@ -42,11 +45,37 @@ export default class WebSocketServerComponent extends ApplicationComponent {
if (!listener) { if (!listener) {
socket.close(1002, `Path not found ${request.url}`); socket.close(1002, `Path not found ${request.url}`);
return; 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}`); 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.');
});
}); });
}); });
} }

View File

@ -59,8 +59,8 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
[key: string]: ModelFieldData; [key: string]: ModelFieldData;
public constructor(isNew: boolean) { public constructor(factory: ModelFactory<never>, isNew: boolean) {
const factory = ModelFactory.get(this.constructor as ModelType<Model>); if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
this._factory = factory; this._factory = factory;
this.init?.(); this.init?.();
this._exists = !isNew; 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; table: string;
new(isNew: boolean): M; new(factory: ModelFactory<never>, isNew: boolean): M;
getPrimaryKeyFields(): (keyof M & string)[]; getPrimaryKeyFields(): (keyof M & string)[];

View File

@ -40,7 +40,7 @@ export default class ModelFactory<M extends Model> {
} }
public create(data: Pick<M, keyof M>, isNewModel: boolean): M { 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) { for (const component of this.components) {
model.addComponent(new component(model)); model.addComponent(new component(model));
} }

View File

@ -2,14 +2,14 @@ import {Connection} from "mysql";
import {ServerError} from "../HttpError.js"; import {ServerError} from "../HttpError.js";
import Model, {ModelType} from "./Model.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 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( public static async validate(
validationMap: { [p: string]: Validator<ModelFieldData> }, validationMap: { [p: string]: Validator<unknown> },
body: { [p: string]: ModelFieldData }, body: { [p: string]: unknown },
): Promise<void> { ): Promise<void> {
const bag = new ValidationBag(); const bag = new ValidationBag();

View File

@ -27,7 +27,7 @@ describe('Register with username and password (password)', () => {
test('General case', async () => { test('General case', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') ?? []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
// Register user // Register user
@ -90,7 +90,7 @@ describe('Register with username and password (password)', () => {
.count()).toStrictEqual(0); .count()).toStrictEqual(0);
const res1 = await agent.get('/csrf').expect(200); const res1 = await agent.get('/csrf').expect(200);
const cookies = res1.get('Set-Cookie') || []; const cookies = res1.get('Set-Cookie');
const csrf = res1.text; const csrf = res1.text;
// Register user // Register user
@ -139,7 +139,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => { describe('Register with email (magic_link)', () => {
test('General case', async () => { test('General case', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'})) 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 () => { test('Cannot register without specifying username', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
res = await agent.post('/auth/register') res = await agent.post('/auth/register')
@ -198,7 +198,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken username', async () => { test('Cannot register taken username', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -240,7 +240,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken email', async () => { test('Cannot register taken email', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -284,7 +284,7 @@ describe('Register with email (magic_link)', () => {
describe('Authenticate with username and password (password)', () => { describe('Authenticate with username and password (password)', () => {
test('Force auth_method', async () => { test('Force auth_method', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -319,7 +319,7 @@ describe('Authenticate with username and password (password)', () => {
test('Automatic auth_method', async () => { test('Automatic auth_method', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -352,7 +352,7 @@ describe('Authenticate with username and password (password)', () => {
test('Non-existing username', async () => { test('Non-existing username', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -385,7 +385,7 @@ describe('Authenticate with username and password (password)', () => {
test('No password user', async () => { test('No password user', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -440,14 +440,13 @@ describe('Authenticate with username and password (password)', () => {
describe('Authenticate with email (magic_link)', () => { describe('Authenticate with email (magic_link)', () => {
test('Force auth_method', async () => { test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
await agent.get('/is-auth').set('Cookie', cookies).expect(401); await agent.get('/is-auth').set('Cookie', cookies).expect(401);
// Authenticate // Authenticate
// TODO deprecated querystring => URLSearchParams
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'})) await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.set('Cookie', cookies) .set('Cookie', cookies)
.send({ .send({
@ -465,7 +464,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Automatic auth_method', async () => { test('Automatic auth_method', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -488,7 +487,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Non-existing email (forced auth_method)', async () => { test('Non-existing email (forced auth_method)', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -509,7 +508,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Non-existing email (automatic auth_method)', async () => { test('Non-existing email (automatic auth_method)', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -531,7 +530,7 @@ describe('Authenticate with email (magic_link)', () => {
describe('Authenticate with email and password (password)', () => { describe('Authenticate with email and password (password)', () => {
test('Prepare user', async () => { test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -570,7 +569,7 @@ describe('Authenticate with email and password (password)', () => {
test('Force auth_method', async () => { test('Force auth_method', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -605,7 +604,7 @@ describe('Authenticate with email and password (password)', () => {
test('Automatic auth_method', async () => { test('Automatic auth_method', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -642,7 +641,7 @@ describe('Change password', () => {
let cookies: string[], csrf: string; let cookies: string[], csrf: string;
test('Prepare user', async () => { test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -822,7 +821,7 @@ describe('Change password', () => {
test('Can\'t remove password without contact email', async () => { test('Can\'t remove password without contact email', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -864,7 +863,7 @@ describe('Change username', () => {
let cookies: string[], csrf: string; let cookies: string[], csrf: string;
test('Prepare user', async () => { test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -963,7 +962,7 @@ describe('Manage email addresses', () => {
let cookies: string[], csrf: string; let cookies: string[], csrf: string;
test('Prepare user', async () => { test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -1170,7 +1169,7 @@ describe('Session persistence', () => {
test('Not persistent at registration', async () => { test('Not persistent at registration', async () => {
let res = await agent.get('/csrf').expect(200); let res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -1186,12 +1185,12 @@ describe('Session persistence', () => {
await followMagicLinkFromMail(agent, cookies); 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') res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .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 // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')
@ -1218,7 +1217,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf') const res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .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 // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')
@ -1245,7 +1244,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf') const res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .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 // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')

View File

@ -25,7 +25,7 @@ describe('Register with username and password (password)', () => {
test('General case', async () => { test('General case', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
csrf = res.text; csrf = res.text;
// Register user // Register user
@ -63,7 +63,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => { describe('Register with email (magic_link)', () => {
test('General case', async () => { test('General case', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'})) 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)', () => { describe('Authenticate with username and password (password)', () => {
test('Force auth_method', async () => { test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated
@ -134,7 +134,7 @@ describe('Authenticate with username and password (password)', () => {
describe('Authenticate with email (magic_link)', () => { describe('Authenticate with email (magic_link)', () => {
test('Force auth_method', async () => { test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Not authenticated // Not authenticated

View File

@ -22,7 +22,7 @@ test('Approval Mode', () => {
describe('Register with username and password (password)', () => { describe('Register with username and password (password)', () => {
test('Must be disabled', async () => { test('Must be disabled', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
// Register user // Register user
@ -43,7 +43,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => { describe('Register with email (magic_link)', () => {
test('General case', async () => { test('General case', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -77,7 +77,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken email', async () => { test('Cannot register taken email', async () => {
const res = await agent.get('/csrf').expect(200); const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie') || []; const cookies = res.get('Set-Cookie');
const csrf = res.text; const csrf = res.text;
await agent.post('/auth/register') await agent.post('/auth/register')
@ -106,7 +106,7 @@ describe('Register with email (magic_link)', () => {
const res2 = await agent.get('/csrf').expect(200); const res2 = await agent.get('/csrf').expect(200);
await agent.post('/auth/register') await agent.post('/auth/register')
.set('Cookie', res2.get('Set-Cookie') || []) .set('Cookie', res2.get('Set-Cookie'))
.send({ .send({
csrf: res2.text, csrf: res2.text,
auth_method: 'magic_link', auth_method: 'magic_link',

View File

@ -39,7 +39,7 @@ describe('Test CSRF protection', () => {
.expect(401) .expect(401)
.then(res => { .then(res => {
expect(res.text).toContain(`You weren't assigned any CSRF token.`); expect(res.text).toContain(`You weren't assigned any CSRF token.`);
cookies = res.get('Set-Cookie') || []; cookies = res.get('Set-Cookie');
agent.get('/') agent.get('/')
.set('Cookie', cookies) .set('Cookie', cookies)

View File

@ -71,14 +71,14 @@ export function authAppProvider(withUsername: boolean = true, approvalMode: bool
const proof = await PasswordAuthProof.getProofForSession(req.getSession()); const proof = await PasswordAuthProof.getProofForSession(req.getSession());
if (proof) res.sendStatus(200); if (proof) res.sendStatus(200);
else res.sendStatus(404); else res.sendStatus(404);
}, 'has-any-password-auth-proof'); }, 'is-auth');
this.get('/has-any-magic-link', async (req, res) => { this.get('/has-any-magic-link', async (req, res) => {
const proofs = await MagicLink.select() const proofs = await MagicLink.select()
.where('session_id', req.getSession().id) .where('session_id', req.getSession().id)
.get(); .get();
if (proofs.length > 0) res.sendStatus(200); if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(404); else res.sendStatus(404);
}, 'has-any-magic-link'); }, 'is-auth');
} }
}()); }());

6816
yarn.lock

File diff suppressed because it is too large Load Diff