Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2022-03-07 17:52:34 +01:00
commit 839383f3cc
36 changed files with 553 additions and 305 deletions

View File

@ -1,6 +1,6 @@
{
"name": "swaf",
"version": "0.24.10",
"version": "0.25.0",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",

View File

@ -0,0 +1,51 @@
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

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

View File

@ -155,7 +155,7 @@ h1 {
a {
text-decoration: none;
.icon.lucide-external-link { //todo add js
.icon.lucide-external-link {
--icon-size: 16px;
margin-left: 4px;
margin-top: -3px;
@ -166,6 +166,13 @@ 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);
@ -249,7 +256,7 @@ button, .button {
margin-left: 8px;
}
&:hover::after {
&:hover::after:not([disabled]) {
content: "";
position: absolute;
@ -260,4 +267,10 @@ button, .button {
background-color: var(--on-background);
opacity: 0.2;
}
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
}

View File

@ -23,6 +23,46 @@
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 {

View File

@ -17,6 +17,7 @@ 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);

View File

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

View File

@ -15,81 +15,87 @@
</script>
<BaseTemplate title="Account" description="Manage your account settings and data.">
<div class="container">
<div class="panel">
<h2><Icon name="user"/> Personal information</h2>
<section 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}
{#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>
</div>
{#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')}
{#if hasRoute('add-email')}
<Form action={route('add-email')} class="sub-panel"
submitIcon="plus" submitText="Add email address">
<h3>Add an email address:</h3>
@ -98,7 +104,6 @@
hint="An email address we can use to identify you in case you lose access to your account"
required/>
</Form>
{/if}
</section>
</div>
{/if}
</section>
</BaseTemplate>

View File

@ -6,8 +6,8 @@
import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.previousFormData?.['auth_method'] !== 'password';
let loginUsingMagicLink = true;
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
let queryStr = '';
let previousUrl: string = $locals.previousUrl as string | undefined;
@ -22,49 +22,72 @@
description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration">
<div class="container">
{#if hasRoute('login')}
{#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 !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 $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}
<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.has_username}
{#if $locals.hasUsername}
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/>
{/if}
{#if registerUsingMagicLink}
{#if registerUsingMagicLink || !$locals.canRegisterWithPassword}
<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/>
<button on:click={() => registerUsingMagicLink=false} type="button"><Icon name="key"/>Use password</button>
{#if $locals.canRegisterWithPassword}
<button on:click={() => registerUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{/if}
{: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>
@ -72,6 +95,5 @@
</Field>
</Form>
</section>
{/if}
</div>
{/if}
</BaseTemplate>

View File

@ -8,26 +8,24 @@
</script>
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
<div class="container">
<Breadcrumb currentPageTitle="Backend"/>
<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>
<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>
</BaseTemplate>

View File

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

View File

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

View File

@ -67,16 +67,19 @@
}
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;
}
}
}

View File

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

View File

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

View File

@ -8,14 +8,12 @@
</script>
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
<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 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>
</BaseTemplate>

View File

@ -38,14 +38,12 @@
</script>
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<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.`}/>
<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>
</div>
<p class="center">Waiting for you to open the link...</p>
</div>
</BaseTemplate>

View File

@ -2,8 +2,11 @@
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')}
@ -12,8 +15,12 @@
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
<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>
{:else if !noLoginLink}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if}

View File

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

View File

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

View File

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

View File

@ -12,6 +12,15 @@ 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';
}
@ -35,10 +44,12 @@ 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(),
has_username: hasUsername,
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
hasUsername: hasUsername,
hasPassword: hasPassword,
canRegisterWithPassword: hasUsername && hasPassword,
});
}

View File

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

View File

@ -38,4 +38,10 @@ 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>;
}

View File

@ -12,6 +12,7 @@ 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";
@ -141,7 +142,7 @@ export default class MagicLinkController<A extends Application> extends Controll
}
if (await validLink.isAuthorized()) {
validLink.use();
validLink.useLink();
await validLink.save();
await this.performAction(validLink, req, res);
return;
@ -198,8 +199,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (!res.headersSent && user) {
// Auth success
const name = user.asOptional(UserNameComponent)?.getName();
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
res.redirect(req.getIntendedUrl() || route('home'));
}
break;

View File

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

View File

@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
return this.used;
}
public use(): void {
public useLink(): void {
this.used = true;
}

View File

@ -8,6 +8,7 @@ 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";
@ -89,7 +90,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
}
}
req.flash('success', `Welcome, ${user.name}.`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
res.redirect(req.getIntendedUrl() || route('home'));
}
@ -136,8 +137,9 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
}
const user = await passwordAuthProof.getResource();
if (!user) throw new Error('Password auth proof has no user.');
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Your account was successfully created!');
res.redirect(req.getIntendedUrl() || route('home'));
}

View File

@ -0,0 +1,2 @@
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(
duration: number,
short: boolean = false,
skipOneUnitNumber: boolean = false,
skipNumberTextPartWhenSingular: boolean = false,
units: TimeUnit[] = [
this.UNITS.SECOND,
this.UNITS.MINUTE,
@ -77,13 +77,13 @@ export class Time {
this.UNITS.YEAR,
],
): string {
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;
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;
}
}

View File

@ -5,19 +5,6 @@ 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) {

View File

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

View File

@ -1,8 +1,5 @@
import config from "config";
import cookie from "cookie";
import cookieParser from "cookie-parser";
import {Express, Request, Router} from "express";
import {Session} from "express-session";
import {Express, Router} from "express";
import {WebSocketServer} from "ws";
import Application from "../Application.js";
@ -45,37 +42,11 @@ 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}`);
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.');
});
listener.handle(socket, request).catch(err => {
logger.error(err, 'Error in websocket listener.');
});
});
}

View File

@ -1185,12 +1185,12 @@ describe('Session persistence', () => {
await followMagicLinkFromMail(agent, cookies);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')
@ -1217,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=.+; HttpOnly$/);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')
@ -1244,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=\/; HttpOnly$/);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')