Merge branch 'develop'
This commit is contained in:
commit
839383f3cc
@ -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>",
|
||||
|
51
src/SessionWebSocketListener.ts
Normal file
51
src/SessionWebSocketListener.ts
Normal 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>;
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -15,9 +15,11 @@
|
||||
</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."/>
|
||||
@ -30,7 +32,7 @@
|
||||
{#if mainEmail}
|
||||
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $locals.has_name_component}
|
||||
<NamePanel/>
|
||||
@ -41,7 +43,10 @@
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><Icon name="shield"/> Email addresses</h2>
|
||||
<h2 id="emails">
|
||||
<Icon name="shield"/>
|
||||
Email addresses
|
||||
</h2>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
@ -71,7 +76,8 @@
|
||||
<td>{email.email}</td>
|
||||
<td class="actions">
|
||||
<Form action={route('set-main-email')} button
|
||||
submitIcon="refresh-ccw" submitText="Set as main address" submitClass="warning"
|
||||
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>
|
||||
@ -100,5 +106,4 @@
|
||||
</Form>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -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')}
|
||||
<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>
|
||||
{#if $locals.hasPassword}
|
||||
{#if loginUsingMagicLink}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button">
|
||||
<Icon name="key"/>
|
||||
Use password
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button>
|
||||
<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}
|
||||
<Field type="checkbox" name="persist_session" icon="clock" placeholder="Stay logged in on this computer."/>
|
||||
{/if}
|
||||
<Field type="checkbox" name="persist_session" icon="clock"
|
||||
placeholder="Stay logged in on this computer."/>
|
||||
</Form>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
@ -73,5 +96,4 @@
|
||||
</Form>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -8,7 +8,6 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
|
||||
<div class="container">
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
|
||||
<h1>App administration</h1>
|
||||
@ -29,5 +28,4 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -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,6 +69,11 @@
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-mode-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copied-overlay {
|
||||
position: absolute;
|
||||
@ -80,12 +86,33 @@
|
||||
text-align: center;
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.content.hidden {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
{#if buttonMode}
|
||||
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div>
|
||||
<button class="bold button-mode-button" on:click={copy} title="{content}">
|
||||
<Icon name="copy"/>
|
||||
|
||||
{#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>
|
||||
|
||||
@ -93,3 +120,4 @@
|
||||
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
72
src/assets/views/components/NavMenuDropdown.svelte
Normal file
72
src/assets/views/components/NavMenuDropdown.svelte
Normal 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>
|
@ -6,27 +6,34 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,17 +45,23 @@
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
|
||||
@include darkMode {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
@include medium-le {
|
||||
&:hover {
|
||||
@include aHover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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>
|
||||
|
@ -8,7 +8,6 @@
|
||||
</script>
|
||||
|
||||
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
{#if $locals.err}
|
||||
<Message type="error" content={$locals.err}/>
|
||||
@ -17,5 +16,4 @@
|
||||
<p>You can now close this page.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -38,7 +38,6 @@
|
||||
</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.`}/>
|
||||
@ -47,5 +46,4 @@
|
||||
|
||||
<p class="center">Waiting for you to open the link...</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTemplate>
|
||||
|
@ -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('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}
|
||||
|
@ -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') {
|
||||
switch (this.type) {
|
||||
case 'number':
|
||||
case 'range':
|
||||
value = +this.value;
|
||||
break;
|
||||
case 'file':
|
||||
handleFileInput();
|
||||
}
|
||||
|
||||
if (this.type === 'datetime-local') {
|
||||
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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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'));
|
||||
}
|
||||
|
||||
|
2
src/common/StringUtils.ts
Normal file
2
src/common/StringUtils.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const toLucideIconsPascalCase = (string: string): string =>
|
||||
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());
|
@ -67,7 +67,7 @@ export class Time {
|
||||
public static humanizeDuration(
|
||||
duration: number,
|
||||
short: boolean = false,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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,39 +42,13 @@ 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 => {
|
||||
listener.handle(socket, request).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
|
@ -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')
|
||||
|
Loading…
Reference in New Issue
Block a user