Compare commits

..

202 Commits

Author SHA1 Message Date
558afedfb6 code cleanup: use URLSearchParams instead of querystring 2024-09-21 18:44:09 +02:00
aa059527ad test app: fix triplicate route name 2024-09-21 18:40:11 +02:00
6031d3a2e6 app: log all errors (debug) 2024-09-21 18:39:45 +02:00
b59e053cad config/mysql: create database by default, use swaf user with password swaf by default, don't create database in production 2024-09-21 18:39:02 +02:00
8ae520aa07 Upgrade dependencies
- Add a new migration to fix password hash length `IncreaseMagicLinkTokenLengthMigration`
- Fix types
2024-09-21 18:37:51 +02:00
bbd9480000 chore(back): clean leftover console.log 2022-03-07 19:25:25 +01:00
ecb2b13a83 Version 0.25.1 2022-03-07 18:58:49 +01:00
a823503cb4 Upgrade @tsconfig/svelte 2022-03-07 18:58:21 +01:00
f0304c0b9f Upgrade @types/config, @types/node, concurrently, lucide 2022-03-07 18:56:35 +01:00
ae31edb622 Upgrade redis package 2022-03-07 18:42:50 +01:00
d378771ffa Upgrade dependencies 2022-03-07 18:15:48 +01:00
869791b3ad Version 0.25.0 2022-03-07 17:47:28 +01:00
e19a627eb5 fix(front/auth/login form): don't show password login method if there is not UserPasswordComponent 2022-03-07 17:41:20 +01:00
3d960dccf3 fix(front/auth/register form): don't show password register method if there is no UserNameComponent
fixes #44
2022-03-07 17:41:20 +01:00
6714e413a2 feat(front/form): allow forms to be disabled, add disabled buttons style 2022-03-07 17:22:43 +01:00
27e9abc5f4 fix(back/auth): refactor auth success, fix message saying 'Welcome undefined' when there is no UserNameComponent 2022-03-07 17:02:03 +01:00
8c083d562d fix(front/auth/login): show password field if previousFormData has password 2022-03-07 16:31:40 +01:00
4cfcaac1cc fix(front/forms): simplify and fix usage of flashed previousFormData and validation errors 2022-03-06 18:55:06 +01:00
3eb33c64d7 fix(front/Field): prevent label from having a greater z-index than the mobile menu 2022-03-06 18:46:33 +01:00
fd2852c387 feat(front/NavMenu): put logout button on the Account link under an extendable dropdown menu 2022-03-06 18:37:46 +01:00
9f17c5b8cd feat(front/style): add hr default style 2022-03-06 18:36:02 +01:00
144a72895e fix(front/account): fix first panel tag name (div->section) 2022-03-06 18:35:41 +01:00
81c65344a9 chore(front): remove unnecessary containers and reformat 2022-03-06 16:57:31 +01:00
41a083ba52 feat(back/auth): add use() method to AuthProof and call it on successful login attempt 2022-03-06 16:34:57 +01:00
0c4349fac3 fix(common/Time): fix humanizeDuration for duration > 1 year 2022-03-06 13:22:12 +01:00
cb5001ce6e fix(front/CopyableText): don't display title when not provided 2022-03-06 13:21:04 +01:00
ee56113808 fix(front/FlashMessages): only display relevant flash bags 2022-03-06 13:20:20 +01:00
2c3286d313 feat(front/data-table): add actions cell, col-grow and col-grow-cell 2022-03-05 10:05:13 +01:00
2e3c5d16c4 fix(back/flash): retrieve all flashed fields 2022-03-05 10:03:49 +01:00
81a62be38d feat(front/CopyableText): add simple button mode 2022-03-02 11:36:23 +01:00
afef367e59 chore(front): cleanup outdated todo 2022-03-02 10:23:19 +01:00
1e72ec7172 fix(front/Icon): better match icon type against Lucide Icons full name instead of starting with fa to avoid collisions 2022-03-02 10:22:55 +01:00
0e0e633e08 feat(front/Form): allow overriding onSubmit function 2022-02-28 14:43:36 +01:00
7a49e47ae7 feat(front/Field): expose file list 2022-02-28 14:42:36 +01:00
67dc33adf4 fix(front/checkbox): prevent checkbox double click event 2022-02-19 11:58:36 +01:00
231aa8dcd7 fix(front/checkbox): change value to boolean and use handleInput 2022-02-19 11:30:06 +01:00
535c8afdb1 fix(websockets): send cookies manually for session authentication 2022-02-18 22:59:16 +01:00
dad4ff62f1 Version 0.24.10 2021-12-03 00:42:20 +01:00
8626d0b571 Upgrade dependencies 2021-12-03 00:42:12 +01:00
32a1721ef2 front/BaseTemplate: add facebook, twitter and generic url preview meta tags 2021-12-03 00:40:31 +01:00
689c860e2e front/ErrorTemplate: fix header logo being cropped 2021-11-28 21:58:06 +01:00
7672a568fc Version 0.24.9 2021-11-28 21:28:06 +01:00
d6b530d16c Add formatViewData to response object to fix tests and prepare for async navigation 2021-11-28 21:26:45 +01:00
366e48757e back/config: replace localhost with 127.0.0.1 2021-11-28 21:24:51 +01:00
ae603362e9 back/redis: fix not using redis.prefix config 2021-11-28 17:06:07 +01:00
be0676611a Upgrade dependencies 2021-11-28 17:02:18 +01:00
b85573a64d front: add Loader component 2021-11-28 16:44:51 +01:00
172d27a6d2 front/NavMenuItem: remove margin top/left on first menu item 2021-11-28 16:44:27 +01:00
e033baa57f front/BaseTemplate: allow disabling login link and logo label 2021-11-28 16:43:46 +01:00
d1ff09fcc8 front/BaseFooter: fix app version local 2021-11-28 16:42:22 +01:00
9674fc87dd front/SvelteViewEngine: add isSsr local 2021-11-28 16:41:46 +01:00
c606379bcd front/SvelteViewEngine: don't use css cache in development environment 2021-11-25 00:25:50 +01:00
179cb09b58 back/Application: fix fallback error template view name 2021-11-24 22:18:14 +01:00
9980c54fcf front/SvelteViewEngine: pre-compile ssr on demand, refactor globals into proper locals and lazy locals 2021-11-24 22:18:14 +01:00
2b85bea9dd common/Routing: allow RouteParams to be undefined and ignore them in this case 2021-11-24 18:20:02 +01:00
4c895229ec back/cli: fix main command not able to accept args 2021-11-24 18:20:02 +01:00
e1542ae476 back/migration command: add log to show that we are rolling back a migration and which one 2021-11-24 18:20:02 +01:00
59491d63ab front/Field: add support for datetime-local field type 2021-11-24 18:20:02 +01:00
aa1484749e front/BaseTemplate: allow disabling header, h1 and footer via boolean attributes 2021-11-24 18:16:16 +01:00
f801f6a43b Add missing dev dependency @types/node 2021-11-24 17:38:16 +01:00
f68e81836b Allow forms to have a file upload enctype 2021-11-22 21:30:23 +01:00
6348692473 Fix select field value bind 2021-11-22 21:29:54 +01:00
e151c9c744 Fix FileUploadMiddleware formidable usage 2021-11-22 17:59:32 +01:00
2e68cf8cae Version 0.24.8 2021-11-21 20:24:56 +01:00
f55ba4d611 Upgrade svelte-preprocess 2021-11-21 20:24:35 +01:00
5cfdecfebf Also replace icons afterUpdate 2021-11-21 20:18:15 +01:00
10d5d16967 Watch DOM changes to add external link icons to new external links 2021-11-21 20:17:49 +01:00
7f4996c908 Add fonts copy to rollup config 2021-11-21 18:34:36 +01:00
92c6433235 Version 0.24.7 2021-11-21 17:06:45 +01:00
48de7829ec Upgrade dependencies 2021-11-21 17:06:28 +01:00
9998f1a91c Make require-from-string a normal dependency for dependants 2021-11-21 17:06:06 +01:00
c7d0238d22 Version 0.24.6 2021-11-21 16:50:47 +01:00
b24e9ab580 front: add CopyableText component 2021-11-21 16:10:38 +01:00
e9db1f4ded front: add an external link icon to external links 2021-11-21 15:02:31 +01:00
428990dc00 front/home page: make first word of the title choosen at random in a list 2021-11-21 13:15:15 +01:00
c0918b17ed Rename front layouts to templates 2021-11-21 13:14:23 +01:00
da6fda02a9 Replace feather icons with lucide icons, use Icon component everywhere 2021-11-21 12:42:26 +01:00
297bafcdc8 Add style to error pages 2021-11-20 22:55:25 +01:00
61e7282f25 Version 0.24.5 2021-11-20 19:52:59 +01:00
d40481fe3b Make rollup available in dependant projects 2021-11-20 19:52:44 +01:00
9da35de4e0 Move design page view to test views 2021-11-20 19:22:04 +01:00
febde935e3 Make more route usage optional 2021-11-20 19:21:15 +01:00
3c28bd9fbe Version 0.24.4 2021-11-20 19:09:14 +01:00
3616d54d29 frontend/BaseLayout: make it easier to edit the BaseLayout without redefining everything 2021-11-20 19:04:55 +01:00
e97cbb5d7f Routing: add hasRoute and hasAnyRoute and make optional components views not render route if they don't exist 2021-11-20 18:02:00 +01:00
c0245e3e3d Move tests page view to test folder 2021-11-20 17:29:17 +01:00
e188458f9c SvelteViewEngine: fix stores file path 2021-11-20 17:29:17 +01:00
2f2e1f51b8 Fix dependencies for sources that will be rebuilt by dependant projects 2021-11-20 16:17:18 +01:00
575de5ebb1 Copy assets to dist folder at dist step 2021-11-20 15:43:20 +01:00
876d509f10 Version 0.24.3 2021-11-20 15:40:22 +01:00
a03dd994e3 Upgrade dependencies 2021-11-20 15:39:51 +01:00
7dde32edb4 Distribute src/assets 2021-11-20 15:39:51 +01:00
a753122290 Do not distribute backend source, export everything to commonjs 2021-11-20 15:39:51 +01:00
5869ba3ee3 Version 0.24.2 2021-11-10 19:26:06 +01:00
ce39f82e66 Copy package.json to dist folder for release 2021-11-10 19:25:50 +01:00
45bd805969 Version 0.24.1 2021-11-10 17:59:44 +01:00
f41ee9cf32 Remove deprecated migrations 2021-11-10 17:58:52 +01:00
404a2ecb16 Fix usages of explicit any 2021-11-10 17:58:52 +01:00
4a09d2e1fe Fix linting on frontend TS
Add return type (void) to replaceIcons function
2021-11-10 17:09:30 +01:00
ef9cb663e7 Add specific tsconfig for eslint 2021-11-10 17:07:05 +01:00
e84b7cf6d3 Version 0.24.0 2021-11-10 16:41:29 +01:00
d6483af1a9 Fix linting errors 2021-11-09 19:46:51 +01:00
e31598fe4e Merge pull request 'Add svelte as a view engine to swaf' (#33) from svelte into develop
Reviewed-on: https://eternae.ink/ashpie/swaf/pulls/33

- config overhaul
- package.json / yarn.lock
  - new scripts, improved
  - now a module
  - new deps and upgraded deps
- rollup instead of webpack
- `assets` folder moved to `src/assets` folder
- `views` moved to `src/assets/views` folder
- make svelte the default view engine (except for mails)
- lots of refactoring
- add personnal information fields to account page
- and more
2021-11-09 19:31:22 +01:00
7650238183 data-table: add data-table-container to overflow-x scroll, use it on relevant pages 2021-11-09 19:29:10 +01:00
5336940dc3 auth page: remove useless hint, use better icon for "Use magic link" button 2021-11-09 19:22:52 +01:00
c29024bb23 Add icons to account page 2021-11-09 19:22:10 +01:00
c5b6b33abd Make magic link lobby messages sticky 2021-11-09 19:15:26 +01:00
54bed4ad7f Add icons to auth page forms 2021-11-09 19:15:03 +01:00
cae7adcee8 Add data-table css component 2021-11-09 18:58:21 +01:00
4d9dae5e3b Allow duration field to specify labels of sub-fields 2021-11-09 18:51:08 +01:00
4a99a5acf5 Fix fieldset and number input display 2021-11-09 18:34:01 +01:00
a4e48eb174 Fix duration field components input displacement 2021-11-09 18:07:53 +01:00
6cd98929d3 Fix Field textarea focus label move 2021-11-09 18:07:31 +01:00
5cea1b866e Fix Field textarea element position and size 2021-11-09 18:00:52 +01:00
baeea368ff Fix pagination second ellipsis 2021-11-09 17:49:56 +01:00
ee4ca0f49b Make pagination and Breadcrumb overflow-x scroll 2021-11-09 17:49:39 +01:00
8254c6cb47 Add breadcrumb to accounts_approval page 2021-11-09 17:43:33 +01:00
ae1d743f15 Add breadcrumb design 2021-11-09 17:43:14 +01:00
9b3822d7f3 Add subsurface for panels in panels, add pagination css/design 2021-11-09 17:32:51 +01:00
93be06d10f Make form submit button bold by default, add optional reset button 2021-11-09 16:54:37 +01:00
e9acde6313 Merge branch 'develop' into svelte 2021-11-08 14:11:47 +01:00
95f6333d6a Handle all existing magic links at once 2021-11-08 13:22:20 +01:00
7d2b088635 Fix no icon fields input and label shift 2021-11-08 01:44:26 +01:00
eefb6e0dac Make tests pass 2021-11-08 01:21:51 +01:00
e7695b7027 Upgrade dependencies 2021-11-08 01:09:26 +01:00
941dc3700e Add FileSize helper class 2021-11-08 00:25:28 +01:00
4a8a1f2da8 More work on default theme and components 2021-11-08 00:24:53 +01:00
f1ae6f6a7b Move frontend tests to a dedicated page 2021-06-02 18:30:20 +02:00
3b636359c6 [TO SQUASH] fix scss assets build pipeline
squash with 7174097388
2021-06-02 18:29:37 +02:00
d0a01ff771 Add images asset type handling 2021-06-02 17:13:46 +02:00
77ff2505b2 CsrfTokenComponent: Use a global empty function for SSR 2021-06-02 17:13:01 +02:00
533cef5ab8 Use user id to throttle failed login attempts instead of name
This allows UserNameComponent to be optional
2021-06-02 16:48:58 +02:00
9ac42bb3db Convert all views to svelte 2021-06-01 16:14:24 +02:00
64cec2987d AccountController: serialize user personal info fields 2021-06-01 16:08:01 +02:00
d925237233 Move pagination to common, add serialization, update BackendController 2021-06-01 16:04:43 +02:00
7174097388 Add scss assets handling 2021-06-01 16:03:07 +02:00
dffbf296ed [TO SQUASH] Add missing src/assets/ts/tsconfig.json 2021-06-01 14:42:58 +02:00
0f415144dc Reorganize many root config parameters 2021-06-01 14:38:53 +02:00
7ccd335649 Move route building to common subproject, fix Time export 2021-06-01 14:34:10 +02:00
1b221c590f package.json: dev script: preserve watch output on tsc 2021-05-31 16:22:14 +02:00
2da637f51d svelte backend calls: stop matching on ',' 2021-05-31 15:34:37 +02:00
4d2dda3615 svelte_layout.html: fix localStore import name 2021-05-31 11:18:46 +02:00
166d1c1458 Swaf export regexp: remove unnecessary escapements 2021-05-31 11:18:04 +02:00
a85d899a21 locals: handle ignored properties, ignore ModelRelation.model 2021-05-31 11:16:46 +02:00
eb6ed8f2d2 Make css properly passed from children to parents with cache and dedup 2021-05-31 11:14:56 +02:00
c6b8c48a72 Move all sources to src folder, add common ts subproject 2021-05-27 15:26:19 +02:00
c36e4c0fbe Make locals override globals instead of the inverse 2021-05-13 17:39:20 +02:00
13bd933b0b svelte: allow locals function calls with no parameter 2021-05-13 17:38:48 +02:00
c9fed2d873 Use maintenance component to throw 503s when some components are unavailable 2021-05-13 16:26:27 +02:00
a3ebf46b54 Add ApplicationComponent init lifecycle step and unstatic globals
This renames ApplicationComponent (previous) init to initRoutes and handle to handleRoutes
2021-05-13 16:03:59 +02:00
cdf95c0c0b Add has and require methods to Extendable 2021-05-13 15:58:41 +02:00
c896accdfa svelte: backend calls: make localStore import .js extension optional 2021-05-13 15:57:31 +02:00
76fa44c245 Convert errors pages to svelte
Fallback non-existing error views to generic Error svelte component
2021-05-13 15:55:56 +02:00
66a696f40e Application: fix isInNodeModules is inverted 2021-05-13 14:13:45 +02:00
26140d2028 svelte: don't eat conditional chains question marks 2021-05-13 14:12:40 +02:00
fac59e12d6 views: delete target file or re-precompile on file remove 2021-05-13 14:11:23 +02:00
c1c7b8920d Add generic type to localStore 2021-05-13 14:08:35 +02:00
afd45bd99d Reorganize frontend tests and add more $locals tests 2021-05-12 14:48:45 +02:00
a4d175addc Handle assets as commonjs 2021-05-12 14:48:02 +02:00
d423d78f2a esm: fix jest not running
following 82ab0b963c
2021-05-12 14:33:18 +02:00
1167e99c30 Svelte view engine: backend calls: don't match dots and quotes 2021-05-12 14:02:58 +02:00
c60bd442e8 Svelte view engine: preprocess dependencies recursively 2021-05-12 14:02:58 +02:00
e851630be4 SvelteViewEngine: update backend calls prefix to $locals (store) 2021-05-12 14:02:58 +02:00
85b4b39dd2 Svelte view engine: also compile backend calls on SSR
Add local to determine whether render is SSR or not
2021-05-12 14:02:58 +02:00
b247d73fcb Use clear-module to clear require cache of dependencies 2021-05-11 15:47:42 +02:00
423f19de68 Only crash when not watching after everything is pre-compiled 2021-05-11 15:14:51 +02:00
076cda8008 Fix localStore set to a function that fetches pre-executed backend calls 2021-05-11 15:13:32 +02:00
911e64a6ae Remove unnecessary svelte preprocess plugin from rollup 2021-05-11 15:08:10 +02:00
8884f70a24 AssetPreCompiler: watch all view directories instead of the first one 2021-05-11 13:54:08 +02:00
42f7ebba05 Svelte: write fully preprocessed code to pre-compiled files 2021-05-11 13:52:48 +02:00
e95595f5a3 package.json dev script: reorder nodemon tasks 2021-05-04 17:39:31 +02:00
4b5111b33a package.json dev script: always clean compile first
This avoids running old code that could modify the databases unexpectedly
2021-05-04 17:34:14 +02:00
50e1b287a9 Catch command line errors and force exit with error code 2021-05-04 17:28:02 +02:00
6aa37eb9e4 Add two step pre-compile/compile asset processing
Reorganize views into new "assets" folder structure
Turn locals into a store so locals don't have to be passed through files that don't need them
Some fixes to previous commit (esm) 82ab0b963c
Remove afs in favor of fs.promises (renamed afs.exists to Utils.doesFileExist
Rename Utils.readdirRecursively to Utils.listFilesRecursively
2021-05-04 17:14:32 +02:00
82ab0b963c Switch to esm and add import auto format 2021-05-03 20:52:52 +02:00
238bbec429 Cancel replaces on ssr html todo 2021-04-30 14:04:42 +02:00
05d112a3b3 Remove NunjucksComponent 2021-04-30 13:59:22 +02:00
c524ddde44 Remove useless node-sass package 2021-04-30 10:50:54 +02:00
e385986aca Svelte: refactor many symbols and safe eval backend calls arguments
Also allow dumping function contents by outputing them directly
2021-04-29 16:13:31 +02:00
bd2b7e7579 svelte render: simplify replaces 2021-04-28 17:03:27 +02:00
05069b15d8 Properly use promises in ViewEngine.render(), use ViewEngine for mail and add NunjucksViewEngine 2021-04-28 17:02:49 +02:00
a7f421d2f8 Move request context locals definition to FrontendToolsComponent 2021-04-28 14:10:29 +02:00
c93ea7691e ViewEngine: demote buildDir and publicDir fields 2021-04-28 14:09:02 +02:00
c4f71e569f Move globals to static ViewEngine and add setGlobal() 2021-04-28 14:05:06 +02:00
0c178955ec Fix svelte template replace would recursively replace 2021-04-28 11:00:51 +02:00
98008517b2 Fix prepare-sources.js comment 2021-04-27 15:44:20 +02:00
c8e9cf963d Fix indent on tsconfig files 2021-04-27 15:44:04 +02:00
fd7a6af98d Remove useless type definitions 2021-04-27 15:43:51 +02:00
9d5c791081 Rollback package.json indent change 2021-04-27 15:10:30 +02:00
58ea522593 Reorganize config params and use view.cache for caching instead of view.dev 2021-04-27 15:08:41 +02:00
faab9e4894 Rollback config files indent change 2021-04-27 15:01:07 +02:00
4f1f88c8f8 Remove useless commandLineArgs.input manipulation 2021-04-27 15:00:43 +02:00
91410b1a15 App startup: add http:// before listen address for conveniance 2021-04-27 14:43:27 +02:00
7b2cdb8269 Clear require cache for watch recompiling 2021-04-23 18:20:22 +02:00
a97e68289e Prevent nodemon infinite loop on yarn dev after yarn clean 2021-04-23 18:19:05 +02:00
10bd8bb95e Upgrade @rollup/plugin-commonjs 2021-04-23 16:11:14 +02:00
4271b6bbc6 Pin svelte-preprocess to 4.6.9
See https://github.com/sveltejs/svelte-preprocess/pull/337
2021-04-23 15:57:34 +02:00
053313002c Move svelte compilation to external rollup and add pre-compile-views cli 2021-04-23 15:56:39 +02:00
e5a9b9908d Refactor and make it nicer codewise 2021-04-23 15:56:39 +02:00
59067e49fe package.json scripts: use NodeJS instead of unix commands 2021-04-23 15:56:06 +02:00
29fa5f4e38 Make svelte work 2021-04-23 15:56:06 +02:00
5f0e11efed Add svelte pre-compilation 2021-04-23 15:56:06 +02:00
218 changed files with 12268 additions and 6344 deletions

135
.eslintrc.cjs Normal file
View File

@ -0,0 +1,135 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'svelte3',
'@typescript-eslint',
'import',
'simple-import-sort',
],
parserOptions: {
tsconfigRootDir: __dirname,
project: [
'./tsconfig.test.json',
'./src/tsconfig.json',
'./src/common/tsconfig.json',
'./src/assets/ts/tsconfig.eslint.json',
'./src/assets/views/tsconfig.json',
]
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
indent: [
'error',
4,
{
SwitchCase: 1
}
],
'no-trailing-spaces': 'error',
'max-len': [
'error',
{
code: 120,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true
}
],
semi: 'off',
'@typescript-eslint/semi': [
'error'
],
'no-extra-semi': 'error',
'eol-last': 'error',
'comma-dangle': 'off',
'simple-import-sort/imports': 'error',
'no-extra-parens': 'off',
'no-nested-ternary': 'error',
'no-return-await': 'off',
'no-useless-return': 'error',
'no-useless-constructor': 'off',
'import/extensions': ['error', 'ignorePackages'],
'@typescript-eslint/comma-dangle': [
'error',
{
arrays: 'always-multiline',
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
functions: 'always-multiline',
enums: 'always-multiline',
generics: 'always-multiline',
tuples: 'always-multiline'
}
],
'@typescript-eslint/no-extra-parens': [
'error'
],
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_'
}
],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-useless-constructor': [
'error'
],
'@typescript-eslint/return-await': [
'error',
'always'
],
'@typescript-eslint/explicit-member-accessibility': [
'error',
{
accessibility: 'explicit'
}
],
'@typescript-eslint/no-floating-promises': 'error',
},
ignorePatterns: [
'.eslintrc.js',
'rollup.config.js',
'jest.config.js',
'dist/**/*',
'config/**/*',
'intermediates/**/*',
'public/**/*',
'scripts/**/*',
'src/frontend/register_svelte/register_svelte.js',
],
overrides: [
{
files: [
'test/**/*'
],
rules: {
'max-len': [
'error',
{
code: 120,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true,
ignoreStrings: true
}
]
}
},
{
files: ['*.svelte'],
processor: 'svelte3/svelte3'
}
],
settings: {
'svelte3/typescript': require('typescript'),
'svelte3/ignore-styles': function (attributes) {
return !!(attributes['lang'] && attributes['lang'] !== 'css');
}
},
}

View File

@ -1,111 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./tsconfig.test.json"
]
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-trailing-spaces": "error",
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"semi": "off",
"@typescript-eslint/semi": [
"error"
],
"no-extra-semi": "error",
"eol-last": "error",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline",
"enums": "always-multiline",
"generics": "always-multiline",
"tuples": "always-multiline"
}
],
"no-extra-parens": "off",
"@typescript-eslint/no-extra-parens": [
"error"
],
"no-nested-ternary": "error",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-non-null-assertion": "error",
"no-useless-return": "error",
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": [
"error"
],
"no-return-await": "off",
"@typescript-eslint/return-await": [
"error",
"always"
],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/no-floating-promises": "error"
},
"ignorePatterns": [
"jest.config.js",
"scripts/**/*",
"dist/**/*",
"config/**/*"
],
"overrides": [
{
"files": [
"test/**/*"
],
"rules": {
"max-len": [
"error",
{
"code": 120,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true,
"ignoreStrings": true
}
]
}
}
]
}

5
.gitignore vendored
View File

@ -1,7 +1,10 @@
.idea
node_modules
dist
intermediates
public
yarn-error.log
src/package.json
config/local.*
tsconfig.tsbuildinfo

View File

@ -1,38 +1,28 @@
{
asset_cache: false,
gitlab_webhook_token: 'default',
app: {
listen_addr: '127.0.0.1',
port: 4899,
public_url: "http://127.0.0.1:4899",
public_websocket_url: "ws://127.0.0.1:4899",
name: 'Example App',
contact_email: 'contact@example.net',
display_email_warning: true,
},
auth: {
// Registered accounts need to be approved by an administrator
approval_mode: false,
// 30 days
name_change_wait_period: 2592000000,
},
log: {
level: "DEBUG",
verbose: true,
db_level: "ERROR",
},
public_url: "http://localhost:4899",
public_websocket_url: "ws://localhost:4899",
listen_addr: '127.0.0.1',
port: 4899,
gitlab_webhook_token: 'default',
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "swaf",
create_database_automatically: false,
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'swaf',
},
session: {
secret: 'default',
cookie: {
secure: false,
maxAge: 31557600000, // 1 year
},
magic_link: {
validity_period: 20,
},
mail: {
host: "127.0.0.1",
@ -44,14 +34,29 @@
from: 'contact@example.net',
from_name: 'Example App',
},
mysql: {
connectionLimit: 10,
host: "127.0.0.1",
user: "swaf",
password: "swaf",
database: "swaf",
create_database_automatically: true,
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'swaf',
},
session: {
secret: 'default',
cookie: {
secure: false,
// 1 year
maxAge: 31557600000,
},
},
view: {
cache: false,
},
magic_link: {
validity_period: 20,
},
auth: {
approval_mode: false, // Registered accounts need to be approved by an administrator
name_change_wait_period: 2592000000, // 30 days
dev: true,
},
}

View File

@ -1,21 +1,31 @@
{
asset_cache: true,
app: {
public_url: "https://swaf.example",
public_websocket_url: "wss://swaf.example",
},
log: {
level: "DEV",
verbose: false,
db_level: "ERROR",
},
public_url: "https://swaf.example",
public_websocket_url: "wss://swaf.example",
session: {
cookie: {
secure: true,
},
magic_link: {
validity_period: 900,
},
mail: {
secure: true,
allow_invalid_tls: false,
},
magic_link: {
validity_period: 900,
mysql: {
create_database_automatically: false,
},
session: {
cookie: {
secure: true,
},
},
view: {
cache: true,
dev: false,
}
}

View File

@ -1,18 +1,15 @@
{
mysql: {
host: "localhost",
user: "root",
password: "",
database: "swaf_test",
create_database_automatically: true,
},
session: {
cookie: {
// 1s
maxAge: 1000,
},
},
auth: {
approval_mode: true,
},
auth: {
approval_mode: true,
},
mysql: {
database: "swaf_test",
create_database_automatically: true,
},
session: {
cookie: {
// 1s
maxAge: 1000,
},
},
}

View File

@ -16,5 +16,5 @@ module.exports = {
'**/test/**/*.test.ts'
],
testEnvironment: 'node',
testTimeout: 60000,
resolver: "jest-ts-webcompat-resolver",
};

View File

@ -1,82 +1,108 @@
{
"name": "swaf",
"version": "0.23.10",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",
"license": "MIT",
"readme": "README.md",
"publishConfig": {
"registry": "https://registry.npmjs.com",
"access": "public"
},
"main": "dist/main.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "jest --verbose --runInBand",
"clean": "node scripts/clean.js",
"prepare-sources": "node scripts/prepare-sources.js",
"compile": "yarn clean && tsc",
"build": "yarn prepare-sources && yarn compile && node scripts/dist.js",
"dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"maildev\"",
"lint": "eslint .",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
},
"devDependencies": {
"@types/compression": "^1.7.0",
"@types/config": "^0.0.38",
"@types/connect-flash": "^0.0.36",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.0",
"@types/formidable": "^1.0.31",
"@types/geoip-lite": "^1.1.31",
"@types/jest": "^26.0.4",
"@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10",
"@types/nanoid": "^2.1.0",
"@types/node-fetch": "^2.5.7",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1",
"@types/redis": "^2.8.18",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0",
"@types/ws": "^7.2.4",
"@typescript-eslint/eslint-plugin": "^4.2.0",
"@typescript-eslint/parser": "^4.2.0",
"concurrently": "^6.0.0",
"eslint": "^7.9.0",
"jest": "^26.1.0",
"maildev": "^1.1.0",
"node-fetch": "^2.6.0",
"nodemon": "^2.0.6",
"supertest": "^6.0.0",
"ts-jest": "^26.1.1",
"typescript": "^4.0.2"
},
"dependencies": {
"argon2": "^0.27.0",
"compression": "^1.7.4",
"config": "^3.3.1",
"connect-flash": "^0.1.1",
"cookie": "^0.4.1",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-session": "^1.17.1",
"formidable": "^1.2.2",
"geoip-lite": "^1.4.2",
"mjml": "^4.6.2",
"mysql": "^2.18.1",
"nanoid": "^3.1.20",
"nodemailer": "^6.4.6",
"nunjucks": "^3.2.1",
"on-finished": "^2.3.0",
"redis": "^3.0.2",
"ts-node": "^9.0.0",
"tslog": "^3.0.1",
"uuid": "^8.0.0",
"ws": "^7.2.3"
}
"name": "swaf",
"version": "0.25.1",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",
"license": "MIT",
"readme": "README.md",
"publishConfig": {
"registry": "https://registry.npmjs.com",
"access": "public"
},
"main": "dist/main.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "jest --verbose --runInBand",
"clean": "node scripts/clean.js",
"prepare-sources": "node scripts/prepare-sources.js",
"compile": "yarn clean && yarn prepare-sources && tsc --build",
"build": "yarn compile && node . pre-compile-views && node scripts/dist.js",
"build-production": "NODE_ENV=production yarn build",
"dev": "yarn compile && concurrently -k -n \"Maildev,Typescript,ViewPreCompile,Node\" -p \"[{name}]\" -c \"yellow,blue,red,green\" \"maildev --ip 127.0.0.1\" \"tsc --build --watch --preserveWatchOutput\" \"nodemon -i public -i intermediates -- pre-compile-views --watch\" \"nodemon -i public -i intermediates\"",
"lint": "eslint .",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
},
"devDependencies": {
"@sveltejs/eslint-config": "sveltejs/eslint-config",
"@tsconfig/svelte": "^3.0.0",
"@types/compression": "^1.7.0",
"@types/config": "^0.0.41",
"@types/connect-flash": "^0.0.37",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.0",
"@types/formidable": "^2.0.0",
"@types/geoip-lite": "^1.1.31",
"@types/jest": "^27.0.2",
"@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10",
"@types/node": "^20.15.0",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1",
"@types/require-from-string": "^1.2.0",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0",
"chokidar": "^3.5.1",
"concurrently": "^7.0.0",
"eslint": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.1.2",
"jest": "^27.3.1",
"jest-resolve": "^27.3.1",
"jest-ts-webcompat-resolver": "^1.0.0",
"maildev": "^2.1.0",
"node-fetch": "^3.0.0",
"nodemon": "^2.0.6",
"sass": "^1.32.12",
"supertest": "^6.0.0",
"svelte-check": "^2.2.8",
"ts-jest": "^27.0.7",
"typescript": "^4.0.2"
},
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.0.6",
"@rollup/plugin-url": "^6.1.0",
"argon2": "^0.28.2",
"clear-module": "^4.1.1",
"compression": "^1.7.4",
"config": "^3.3.1",
"connect-flash": "^0.1.1",
"cookie": "^0.4.1",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-session": "^1.17.1",
"formidable": "^2.0.1",
"geoip-lite": "^1.4.2",
"lucide": "^0.17.7",
"mjml": "^4.6.2",
"mysql": "^2.18.1",
"nanoid": "^3.1.20",
"nodemailer": "^6.4.6",
"normalize.css": "^8.0.1",
"nunjucks": "^3.2.1",
"on-finished": "^2.3.0",
"redis": "^4.0.4",
"require-from-string": "^2.0.2",
"rollup": "^2.42.3",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-imagemin": "^0.4.1",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.35.0",
"svelte-preprocess": "^4.9.8",
"ts-node": "^10.4.0",
"tslog": "^3.0.1",
"uuid": "^8.0.0",
"ws": "^8.2.3"
}
}

74
rollup.config.js Normal file
View File

@ -0,0 +1,74 @@
import path from "path";
import svelte from "rollup-plugin-svelte";
import cssOnlyRollupPlugin from "rollup-plugin-css-only";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import {terser} from "rollup-plugin-terser";
import livereloadRollupPlugin from "rollup-plugin-livereload";
import imageminPlugin from "rollup-plugin-imagemin";
import url from "@rollup/plugin-url";
const production = process.env.ENV === 'production';
const buildDir = process.env.BUILD_DIR;
const publicDir = process.env.PUBLIC_DIR;
const input = process.env.INPUT.split(':');
export default commandLineArgs => ({
input: input,
output: {
format: 'es',
sourcemap: true,
dir: path.join(publicDir, 'js'),
entryFileNames: (chunkInfo) => {
const name = chunkInfo.facadeModuleId ?
path.relative(buildDir, chunkInfo.facadeModuleId) :
chunkInfo.name;
return name + '.js';
},
chunkFileNames: '[name].js',
},
plugins: [
imageminPlugin({
fileName: '../img/[name][extname]'
}),
svelte({
compilerOptions: {
dev: !production,
hydratable: true,
},
}),
// Extract css into separate files
cssOnlyRollupPlugin({output: 'bundle.css'}),
url({
include: [
'**/*.woff2?',
'**/*.ttf',
],
limit: 0,
fileName: path.join('../', 'fonts', '[name][extname]'),
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte'],
}),
commonjs(),
// Live reload in dev
!production && !!commandLineArgs.watch && livereloadRollupPlugin(publicDir),
// Minify in production
production && terser(),
],
watch: {
clearScreen: false,
},
});

22
scripts/_functions.js Normal file
View File

@ -0,0 +1,22 @@
const fs = require('fs');
const path = require('path');
function copyRecursively(file, destination) {
const target = path.join(destination, path.basename(file));
if (fs.statSync(file).isDirectory()) {
console.log('mkdir', target);
fs.mkdirSync(target, {recursive: true});
fs.readdirSync(file).forEach(f => {
copyRecursively(path.join(file, f), target);
});
} else {
console.log('> cp ', target);
fs.copyFileSync(file, target);
}
}
module.exports = {
copyRecursively,
};

View File

@ -1,7 +1,9 @@
const fs = require('fs');
[
'intermediates',
'dist',
'public',
].forEach(file => {
if (fs.existsSync(file)) {
console.log('Cleaning', file, '...');

View File

@ -1,28 +1,13 @@
const fs = require('fs');
const path = require('path');
function copyRecursively(file, destination) {
const target = path.join(destination, path.basename(file));
if (fs.statSync(file).isDirectory()) {
console.log('mkdir', target);
fs.mkdirSync(target, {recursive: true});
fs.readdirSync(file).forEach(f => {
copyRecursively(path.join(file, f), target);
});
} else {
console.log('> cp ', target);
fs.copyFileSync(file, target);
}
}
const {copyRecursively} = require('./_functions.js');
[
'yarn.lock',
'README.md',
'config/',
'views/',
'rollup.config.js',
].forEach(file => {
copyRecursively(file, 'dist');
});
@ -32,3 +17,7 @@ fs.mkdirSync('dist/types', {recursive: true});
fs.readdirSync('src/types').forEach(file => {
copyRecursively(path.join('src/types', file), 'dist/types');
});
fs.readdirSync('src/assets').forEach(file => {
copyRecursively(path.join('src/assets', file), 'dist/assets');
});

View File

@ -1,4 +1,21 @@
const fs = require('fs');
const path = require('path');
fs.copyFileSync('package.json', path.join('src', 'package.json'));
// These folders must exist for nodemon not to loop indefinitely.
[
'public',
'dist',
'intermediates',
'intermediates/assets',
].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
});
// Symlink to build/common
const symlink = path.resolve('intermediates/common');
if (!fs.existsSync(symlink)) {
fs.symlinkSync(path.resolve('dist/common'), symlink);
}
// Copy package.json
fs.copyFileSync('package.json', 'dist/package.json');

View File

@ -1,23 +1,28 @@
import express, {NextFunction, Request, Response, Router} from 'express';
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError";
import {lib} from "nunjucks";
import WebSocketListener from "./WebSocketListener";
import ApplicationComponent from "./ApplicationComponent";
import Controller from "./Controller";
import MysqlConnectionManager from "./db/MysqlConnectionManager";
import Migration, {MigrationType} from "./db/Migration";
import {Type} from "./Utils";
import LogRequestsComponent from "./components/LogRequestsComponent";
import {ValidationBag, ValidationError} from "./db/Validator";
import config from "config";
import express, {NextFunction, Request, Response, Router} from 'express';
import * as fs from "fs";
import SecurityError from "./SecurityError";
import nunjucks from "nunjucks";
import * as path from "path";
import CacheProvider from "./CacheProvider";
import RedisComponent from "./components/RedisComponent";
import Extendable from "./Extendable";
import {logger, loggingContextMiddleware} from "./Logger";
import TemplateError = lib.TemplateError;
import ApplicationComponent from "./ApplicationComponent.js";
import CacheProvider from "./CacheProvider.js";
import {route, setPublicUrl} from "./common/Routing.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js";
import RedisComponent from "./components/RedisComponent.js";
import Controller from "./Controller.js";
import Migration, {MigrationType} from "./db/Migration.js";
import MysqlConnectionManager from "./db/MysqlConnectionManager.js";
import {ValidationBag, ValidationError} from "./db/Validator.js";
import Extendable, {MissingComponentError} from "./Extendable.js";
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
import {logger, loggingContextMiddleware} from "./Logger.js";
import SecurityError from "./SecurityError.js";
import {doesFileExist, Type, WrappingError} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
import LazyLocalsCoreComponent from "./components/core/LazyLocalsCoreComponent.js";
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string;
@ -35,6 +40,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
protected constructor(version: string, ignoreCommandLine: boolean = false) {
this.version = version;
this.ignoreCommandLine = ignoreCommandLine;
setPublicUrl(config.get<string>('app.public_url'));
}
protected abstract getMigrations(): MigrationType<Migration>[];
@ -66,9 +73,9 @@ export default abstract class Application implements Extendable<ApplicationCompo
this.busy = true;
// Load core version
const file = this.isInNodeModules() ?
path.join(__dirname, '../../package.json') :
path.join(__dirname, '../package.json');
const file = await this.isInNodeModules() ?
'node_modules/swaf/package.json' :
'package.json';
try {
this.coreVersion = JSON.parse(fs.readFileSync(file).toString()).version;
@ -92,14 +99,29 @@ export default abstract class Application implements Extendable<ApplicationCompo
// Register migrations
MysqlConnectionManager.registerMigrations(this.getMigrations());
// Register all components and alike
// Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init();
for (const component of this.components) {
await component.init?.();
}
// Process command line
if (!this.ignoreCommandLine && await this.processCommandLine()) {
this.started = true;
this.busy = false;
return;
if (!this.ignoreCommandLine) {
let result: boolean;
try {
result = await this.processCommandLine();
} catch (err) {
logger.error(err);
process.exit(1);
}
if (result) {
this.started = true;
this.busy = false;
return;
}
}
// Security
@ -123,6 +145,11 @@ export default abstract class Application implements Extendable<ApplicationCompo
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
logger.debug(err);
if (err instanceof WrappingError) {
logger.debug("-> Caused by", err.cause);
}
// Transform single validation errors into a validation bag for convenience
if (err instanceof ValidationError) {
const bag = new ValidationBag();
@ -148,7 +175,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
},
html: () => {
req.flash('validation', bag.getMessages());
res.redirect(req.getPreviousUrl() || Controller.route('home'));
res.redirect(req.getPreviousUrl() || route('home'));
},
});
return;
@ -170,11 +197,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
res.status(httpError.errorCode);
res.format({
html: () => {
res.render('errors/' + httpError.errorCode + '.njk', {
const locals = {
error_code: httpError.errorCode,
error_message: httpError.message,
error_instructions: httpError.instructions,
error_id: errorId,
};
res.formatViewData('errors/' + httpError.errorCode, locals, (err: Error | undefined, html) => {
if (err) {
res.formatViewData('templates/ErrorTemplate', locals);
} else {
res.send(html);
}
});
},
json: () => {
@ -192,26 +226,26 @@ export default abstract class Application implements Extendable<ApplicationCompo
});
});
// Start components
for (const component of this.components) {
await component.start?.(app);
}
// Components routes
for (const component of this.components) {
if (component.init) {
if (component.initRoutes) {
component.setCurrentRouter(initRouter);
await component.init(initRouter);
await component.initRoutes(initRouter);
}
if (component.handle) {
if (component.handleRoutes) {
component.setCurrentRouter(handleRouter);
await component.handle(handleRouter);
await component.handleRoutes(handleRouter);
}
component.setCurrentRouter(null);
}
// Start components
for (const component of this.components) {
await component.start?.(app);
}
// Routes
this.routes(initRouter, handleRouter);
@ -222,10 +256,12 @@ export default abstract class Application implements Extendable<ApplicationCompo
protected async processCommandLine(): Promise<boolean> {
const args = process.argv;
// Flags
const flags = {
verbose: false,
fullHttpRequests: false,
watch: false,
};
let mainCommand: string | null = null;
const mainCommandArgs: string[] = [];
@ -237,14 +273,22 @@ export default abstract class Application implements Extendable<ApplicationCompo
case '--full-http-requests':
flags.fullHttpRequests = true;
break;
case '--watch':
flags.watch = true;
break;
case 'migration':
case 'pre-compile-views':
if (mainCommand === null) mainCommand = args[i];
else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
break;
default:
if (mainCommand) mainCommandArgs.push(args[i]);
else logger.fatal('Unrecognized argument', args[i]);
return true;
if (mainCommand) {
mainCommandArgs.push(args[i]);
} else {
logger.fatal('Unrecognized argument', args[i]);
return true;
}
break;
}
}
@ -257,6 +301,20 @@ export default abstract class Application implements Extendable<ApplicationCompo
await MysqlConnectionManager.migrationCommand(mainCommandArgs);
await this.stop();
break;
case 'pre-compile-views': {
// Prepare migrations
for (const migration of this.getMigrations()) {
new migration().registerModels?.();
}
// Prepare routes
for (const controller of this.controllers) {
controller.setupRoutes();
}
const frontendToolsComponent = this.as(FrontendToolsComponent);
await frontendToolsComponent.preCompileViews(flags.watch);
break;
}
default:
logger.fatal('Unimplemented main command', mainCommand);
break;
@ -273,8 +331,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
for (const file of fs.readdirSync(configDir)) {
const fullPath = path.resolve(configDir, file);
const stats = fs.lstatSync(fullPath);
if (stats.uid !== process.getuid())
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid()}).`);
if (stats.uid !== process.getuid?.())
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid?.()}).`);
const mode = (stats.mode & parseInt('777', 8)).toString(8);
if (mode !== '400')
@ -332,6 +390,10 @@ export default abstract class Application implements Extendable<ApplicationCompo
return this.cacheProvider || null;
}
public getComponents(): ApplicationComponent[] {
return [...this.components];
}
public as<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C {
const module = this.components.find(component => component.constructor === type) ||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
@ -345,8 +407,18 @@ export default abstract class Application implements Extendable<ApplicationCompo
return module ? module as C : null;
}
public isInNodeModules(): boolean {
return fs.existsSync(path.join(__dirname, '../../package.json'));
public has<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): boolean {
return !!this.asOptional(type);
}
public require<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): void {
if (!this.has(type)) {
throw new MissingComponentError(type);
}
}
public async isInNodeModules(): Promise<boolean> {
return await doesFileExist('node_modules/swaf');
}
public isReady(): boolean {

View File

@ -1,10 +1,11 @@
import {Express, Router} from "express";
import {logger} from "./Logger";
import {sleep} from "./Utils";
import Application from "./Application";
import config from "config";
import SecurityError from "./SecurityError";
import Middleware, {MiddlewareType} from "./Middleware";
import {Express, Router} from "express";
import Application from "./Application.js";
import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js";
import SecurityError from "./SecurityError.js";
import {sleep} from "./Utils.js";
export default abstract class ApplicationComponent {
private currentRouter?: Router;
@ -12,14 +13,20 @@ export default abstract class ApplicationComponent {
public async checkSecuritySettings?(): Promise<void>;
public async init?(): Promise<void>;
public async initRoutes?(router: Router): Promise<void>;
public async handleRoutes?(router: Router): Promise<void>;
public async start?(expressApp: Express): Promise<void>;
public async init?(router: Router): Promise<void>;
public async handle?(router: Router): Promise<void>;
public async stop?(): Promise<void>;
public isReady(): boolean {
return true;
}
protected async prepare(name: string, prepare: () => Promise<void>): Promise<void> {
let err;
do {

View File

@ -1,54 +1,13 @@
import express, {IRouter, RequestHandler, Router} from "express";
import {PathParams} from "express-serve-static-core";
import config from "config";
import {logger} from "./Logger";
import FileUploadMiddleware from "./FileUploadMiddleware";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import Middleware, {MiddlewareType} from "./Middleware";
import Application from "./Application";
import Application from "./Application.js";
import {registerRoute} from "./common/Routing.js";
import FileUploadMiddleware from "./FileUploadMiddleware.js";
import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js";
export default abstract class Controller {
/**
* TODO: this should not be static, it should actually be bound to an app instance.
*/
private static readonly routes: { [p: string]: string | undefined } = {};
public static route(
route: string,
params: RouteParams = [],
query: ParsedUrlQueryInput = {},
absolute: boolean = false,
): string {
let path = this.routes[route];
if (path === undefined) throw new Error(`Unknown route for name ${route}.`);
const regExp = this.getRouteParamRegExp('[a-zA-Z0-9_-]+', 'g');
if (typeof params === 'string' || typeof params === 'number') {
path = path.replace(regExp, '' + params);
} else if (Array.isArray(params)) {
let i = 0;
for (const match of path.matchAll(regExp)) {
if (match.length > 0) {
path = path.replace(match[0], typeof params[i] !== 'undefined' ? params[i] : '');
}
i++;
}
path = path.replace(/\/+/g, '/');
} else {
for (const key of Object.keys(params)) {
path = path.replace(this.getRouteParamRegExp(key), params[key].toString());
}
}
const queryStr = querystring.stringify(query);
return `${absolute ? config.get<string>('public_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
}
private static getRouteParamRegExp(key: string, flags?: string): RegExp {
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags);
}
private readonly router: Router = express.Router();
private readonly fileUploadFormRouter: Router = express.Router();
private app?: Application;
@ -182,13 +141,15 @@ export default abstract class Controller {
routePath = (prefix !== '/' ? prefix : '') + path;
}
if (!Controller.routes[routeName]) {
if (typeof routePath === 'string') {
logger.info(`Route ${routeName} has path ${routePath}`);
Controller.routes[routeName] = routePath;
} else {
logger.warn(`Cannot assign path to route ${routeName}.`);
}
if (typeof routePath !== 'string') {
logger.warn(`Cannot assign path to route ${routeName}.`);
return;
}
if (registerRoute(routeName, routePath)) {
logger.info(`Route ${routeName} has path ${routePath}`);
} else {
logger.warn(`Couldn't register ${routeName} for path ${routePath}`);
}
}
@ -201,5 +162,3 @@ export default abstract class Controller {
this.app = app;
}
}
export type RouteParams = { [p: string]: string | number } | string[] | string | number;

View File

@ -1,7 +1,20 @@
import {Type} from "./Utils";
import {Type} from "./Utils.js";
export default interface Extendable<ComponentClass> {
as<C extends ComponentClass>(type: Type<C>): C;
asOptional<C extends ComponentClass>(type: Type<C>): C | null;
has<C extends ComponentClass>(type: Type<C>): boolean;
/**
* @throws MissingComponentError
*/
require<C extends ComponentClass>(type: Type<C>): void;
}
export class MissingComponentError<ComponentClass> extends Error {
public constructor(type: Type<ComponentClass>) {
super(`Required component ${type.name} was not found.`);
}
}

View File

@ -1,15 +1,16 @@
import Formidable from "formidable";
import Middleware from "./Middleware";
import {NextFunction, Request, Response} from "express";
import {FileError, ValidationBag} from "./db/Validator";
import formidable, {Options} from "formidable";
import {FileError, ValidationBag} from "./db/Validator.js";
import Middleware from "./Middleware.js";
export default abstract class FileUploadMiddleware extends Middleware {
protected abstract makeForm(): Formidable;
protected abstract getFormidableOptions(): Options;
protected abstract getDefaultField(): string;
public async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const form = this.makeForm();
const form = formidable(this.getFormidableOptions());
try {
await new Promise<void>((resolve, reject) => {
form.parse(req, (err, fields, files) => {
@ -24,7 +25,7 @@ export default abstract class FileUploadMiddleware extends Middleware {
});
} catch (e) {
const bag = new ValidationBag();
const fileError = new FileError(e);
const fileError = new FileError(String(e));
fileError.thingName = this.getDefaultField();
bag.addMessage(fileError);
next(bag);

View File

@ -1,4 +1,4 @@
import {WrappingError} from "./Utils";
import {WrappingError} from "./Utils.js";
export abstract class HttpError extends WrappingError {
public readonly instructions: string;
@ -12,7 +12,7 @@ export abstract class HttpError extends WrappingError {
return this.constructor.name;
}
abstract get errorCode(): number;
public abstract get errorCode(): number;
}
export class BadRequestError extends HttpError {

View File

@ -1,7 +1,7 @@
import {Logger as TsLogger} from "tslog";
import {AsyncLocalStorage} from "async_hooks";
import {RequestHandler} from "express";
import {nanoid} from "nanoid";
import {Logger as TsLogger} from "tslog";
const requestIdStorage: AsyncLocalStorage<string> = new AsyncLocalStorage();

View File

@ -1,5 +1,6 @@
import config from "config";
import {MailTemplate} from "./mail/Mail";
import MailTemplate from "./mail/MailTemplate.js";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',

View File

@ -1,7 +1,8 @@
import {RequestHandler} from "express";
import {NextFunction, Request, Response} from "express-serve-static-core";
import Application from "./Application";
import {Type} from "./Utils";
import Application from "./Application.js";
import {Type} from "./Utils.js";
export default abstract class Middleware {
public constructor(

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,38 +1,48 @@
import Application from "./Application";
import Migration, {MigrationType} from "./db/Migration";
import ExpressAppComponent from "./components/ExpressAppComponent";
import RedisComponent from "./components/RedisComponent";
import MysqlComponent from "./components/MysqlComponent";
import NunjucksComponent from "./components/NunjucksComponent";
import LogRequestsComponent from "./components/LogRequestsComponent";
import MailComponent from "./components/MailComponent";
import SessionComponent from "./components/SessionComponent";
import AuthComponent from "./auth/AuthComponent";
import FormHelperComponent from "./components/FormHelperComponent";
import ServeStaticDirectoryComponent from "./components/ServeStaticDirectoryComponent";
import {Express} from "express";
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod";
import PasswordAuthMethod from "./auth/password/PasswordAuthMethod";
import {MAGIC_LINK_MAIL} from "./Mails";
import CreateMigrationsTable from "./migrations/CreateMigrationsTable";
import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration";
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration";
import AuthController from "./auth/AuthController";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener";
import MagicLinkController from "./auth/magic_link/MagicLinkController";
import AddPasswordToUsersMigration from "./auth/password/AddPasswordToUsersMigration";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration";
import CsrfProtectionComponent from "./components/CsrfProtectionComponent";
import MailController from "./mail/MailController";
import WebSocketServerComponent from "./components/WebSocketServerComponent";
import Controller from "./Controller";
import AccountController from "./auth/AccountController";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
import PreviousUrlComponent from "./components/PreviousUrlComponent";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
import BackendController from "./helpers/BackendController";
import packageJson = require('./package.json');
import Application from "./Application.js";
import AccountController from "./auth/AccountController.js";
import AuthComponent from "./auth/AuthComponent.js";
import AuthController from "./auth/AuthController.js";
import AddUsedToMagicLinksMigration from "./auth/magic_link/migrations/AddUsedToMagicLinksMigration.js";
import CreateMagicLinksTableMigration from "./auth/magic_link/migrations/CreateMagicLinksTableMigration.js";
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.js";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/migrations/MakeMagicLinksSessionNotUniqueMigration.js";
import AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.js";
import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration.js";
import AddPasswordToUsersMigration from "./auth/password/AddPasswordToUsersMigration.js";
import PasswordAuthMethod from "./auth/password/PasswordAuthMethod.js";
import CsrfProtectionComponent from "./components/CsrfProtectionComponent.js";
import ExpressAppComponent from "./components/ExpressAppComponent.js";
import FormHelperComponent from "./components/FormHelperComponent.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js";
import MailComponent from "./components/MailComponent.js";
import MaintenanceComponent from "./components/MaintenanceComponent.js";
import MysqlComponent from "./components/MysqlComponent.js";
import PreviousUrlComponent from "./components/PreviousUrlComponent.js";
import RedisComponent from "./components/RedisComponent.js";
import ServeStaticDirectoryComponent from "./components/ServeStaticDirectoryComponent.js";
import SessionComponent from "./components/SessionComponent.js";
import WebSocketServerComponent from "./components/WebSocketServerComponent.js";
import Controller from "./Controller.js";
import Migration, {MigrationType} from "./db/Migration.js";
import AssetCompiler from "./frontend/AssetCompiler.js";
import CopyAssetPreCompiler from "./frontend/CopyAssetPreCompiler.js";
import MailViewEngine from "./frontend/MailViewEngine.js";
import NunjucksViewEngine from "./frontend/NunjucksViewEngine.js";
import ScssAssetPreCompiler from "./frontend/ScssAssetPreCompiler.js";
import SvelteViewEngine from "./frontend/SvelteViewEngine.js";
import TypeScriptPreCompiler from "./frontend/TypeScriptPreCompiler.js";
import BackendController from "./helpers/BackendController.js";
import MailController from "./mail/MailController.js";
import {MAGIC_LINK_MAIL} from "./Mails.js";
import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js";
import IncreaseMagicLinkTokenLengthMigration from "./auth/magic_link/migrations/IncreaseMagicLinkTokenLengthMigration.js";
export const MIGRATIONS = [
CreateMigrationsTable,
@ -43,20 +53,33 @@ export const MIGRATIONS = [
MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration,
AddNameChangedAtToUsersMigration,
IncreaseMagicLinkTokenLengthMigration,
];
export default class TestApp extends Application {
private readonly addr: string;
private readonly port: number;
public constructor(addr: string, port: number, ignoreCommandLine: boolean = false) {
super(packageJson.version, ignoreCommandLine);
public constructor(
version: string,
addr: string,
port: number,
ignoreCommandLine: boolean = false,
private readonly approvalMode: boolean,
) {
super(version, ignoreCommandLine);
this.addr = addr;
this.port = port;
}
protected getMigrations(): MigrationType<Migration>[] {
return MIGRATIONS;
const migrations = [...MIGRATIONS];
if (this.approvalMode) {
migrations.push(AddApprovedFieldToUsersTableMigration);
}
return migrations;
}
protected async init(): Promise<void> {
@ -73,13 +96,26 @@ export default class TestApp extends Application {
// Static files
this.use(new ServeStaticDirectoryComponent('public'));
// Maintenance
this.use(new MaintenanceComponent());
// Dynamic views and routes
this.use(new NunjucksComponent(['test/views', 'views']));
const intermediateDirectory = 'intermediates/assets';
const assetCompiler = new AssetCompiler(intermediateDirectory, 'public');
this.use(new FrontendToolsComponent(
assetCompiler,
new CopyAssetPreCompiler(intermediateDirectory, '', 'json', ['test/assets'], false),
new ScssAssetPreCompiler(intermediateDirectory, assetCompiler.targetDir, 'scss', ['test/assets']),
new CopyAssetPreCompiler(intermediateDirectory, 'img', 'svg', ['test/assets'], true),
new TypeScriptPreCompiler(intermediateDirectory, ['test/assets']),
new SvelteViewEngine(intermediateDirectory, 'test/assets'),
new NunjucksViewEngine(intermediateDirectory, 'test/assets'),
));
this.use(new PreviousUrlComponent());
// Services
this.use(new MysqlComponent());
this.use(new MailComponent());
this.use(new MailComponent(new MailViewEngine('intermediates/assets', 'test/assets')));
// Session
this.use(new RedisComponent());
@ -95,7 +131,7 @@ export default class TestApp extends Application {
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
// WebSocket server
this.use(new WebSocketServerComponent(this, this.as(ExpressAppComponent), this.as(RedisComponent)));
this.use(new WebSocketServerComponent());
}
protected registerWebSocketListeners(): void {
@ -114,8 +150,19 @@ export default class TestApp extends Application {
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.render('home');
res.formatViewData('home');
}, 'home');
this.get('/tests', (req, res) => {
res.formatViewData('tests');
}, 'tests');
this.get('/design', (req, res) => {
req.flash('success', 'Success.');
req.flash('info', 'Info.');
req.flash('warning', 'Warning.');
req.flash('error', 'Error.');
req.flash('error-alert', 'Error alert.');
res.formatViewData('design');
}, 'design');
}
}());
}

View File

@ -1,5 +1,5 @@
import {TooManyRequestsHttpError} from "./HttpError";
import {logger} from "./Logger";
import {TooManyRequestsHttpError} from "./HttpError.js";
import {logger} from "./Logger.js";
export default class Throttler {
private static readonly throttles: Record<string, Throttle | undefined> = {};

View File

@ -1,3 +1,6 @@
import fs, {promises as afs} from "fs";
import path from "path";
export async function sleep(ms: number): Promise<void> {
return await new Promise(resolve => {
setTimeout(() => resolve(), ms);
@ -44,3 +47,35 @@ export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[]
} while (currentObj);
return [...properties.keys()].filter(item => typeof obj[item] === 'function');
}
export async function listFilesRecursively(dir: string): Promise<string[]> {
const localFiles = await afs.readdir(dir);
const files: string[] = [];
for (const file of localFiles.map(file => path.join(dir, file))) {
const stat = await afs.stat(file);
if (stat.isDirectory()) {
files.push(...await listFilesRecursively(file));
} else {
files.push(file);
}
}
return files;
}
export async function doesFileExist(file: string): Promise<boolean> {
return await new Promise<boolean>((resolve, reject) => {
fs.stat(file, err => {
if (err) {
if (err.code === 'ENOENT') {
return resolve(false);
} else {
return reject(err);
}
} else {
return resolve(true);
}
});
});
}

View File

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

239
src/assets/img/logo.svg Normal file
View File

@ -0,0 +1,239 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg5"
inkscape:version="1.1 (c4e8f9ed74, 2021-05-24)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:document-units="px"
showgrid="true"
units="px"
width="256px"
showguides="true"
inkscape:zoom="3.1108586"
inkscape:cx="167.96006"
inkscape:cy="124.88514"
inkscape:window-width="2560"
inkscape:window-height="1408"
inkscape:window-x="1920"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="g2910">
<inkscape:grid
type="xygrid"
id="grid9"
empspacing="8"
color="#808080"
opacity="0.25098039"
empcolor="#3b3bc4"
empopacity="0.25098039" />
</sodipodi:namedview>
<defs
id="defs2">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4993">
<path
style="font-variation-settings:normal;opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.23333;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
sodipodi:type="inkscape:offset"
inkscape:radius="0"
inkscape:original="M 38.130859 9.1289062 C 38.130859 9.1289062 36.869291 14.550899 33.769531 22.080078 C 31.015624 28.769197 25.443612 29.282491 25.431641 38.761719 C 25.421841 46.502055 31.116842 51.460937 38.130859 51.460938 C 45.144876 51.460938 50.795057 46.501973 50.832031 38.761719 C 50.880267 28.66385 49.355438 30.099351 44.685547 20.40625 C 41.206475 13.184883 38.130859 9.1289062 38.130859 9.1289062 z "
xlink:href="#path63"
id="path4995"
inkscape:href="#path63"
d="m 38.130859,9.4160156 c 0,0 -12.699218,21.1661454 -12.699218,29.6328124 0,7.014017 5.685201,12.701172 12.699218,12.701172 7.014017,0 12.701172,-5.687155 12.701172,-12.701172 0,-8.466667 -12.701172,-29.6328124 -12.701172,-29.6328124 z"
transform="translate(-4.2646454,3.5720328)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath5199">
<path
style="font-variation-settings:normal;opacity:1;fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.23333;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
sodipodi:type="inkscape:offset"
inkscape:radius="0"
inkscape:original="M 36.546875 7.2324219 C 36.546875 7.2324219 23.845703 28.40052 23.845703 36.867188 C 23.845703 43.881204 29.532858 49.566406 36.546875 49.566406 C 43.560892 49.566406 49.246094 43.881204 49.246094 36.867188 C 49.246094 28.40052 36.546875 7.2324219 36.546875 7.2324219 z "
xlink:href="#path4972"
id="path5201"
inkscape:href="#path4972"
d="m 36.242188,6.9941406 c 0,0 -12.701172,21.1680984 -12.701172,29.6347654 0,7.014017 5.687155,12.699219 12.701172,12.699219 7.014017,0 12.699218,-5.685202 12.699218,-12.699219 0,-8.466667 -12.699219,-29.6347654 -12.699218,-29.6347654 z"
transform="translate(5.7867919,-2.9996614)" />
</clipPath>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g2910"
style="display:inline">
<g
id="g4378"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="opacity:1;fill:#ff900d;fill-opacity:1;stroke:#ff900d;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect21528"
width="46.566666"
height="46.566666"
x="20.108334"
y="2.1166666" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000"
id="rect52610-8"
width="38.099995"
height="2.1166658"
x="28.575006"
y="21.166666" />
</g>
<g
aria-label="express"
id="text15483-1"
style="font-size:10.0542px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(3.9913951,0.37437841)">
<path
d="m 27.144137,18.146446 q -1.035579,0 -1.648883,-0.633413 -0.613305,-0.643466 -0.67363,-1.749425 -0.01005,-0.130704 -0.01005,-0.331787 0,-0.211138 0.01005,-0.341842 0.04022,-0.713846 0.331788,-1.246717 0.291571,-0.542925 0.794279,-0.834495 0.512763,-0.291571 1.196446,-0.291571 0.764117,0 1.276879,0.321733 0.522817,0.321733 0.794279,0.914929 0.271463,0.593196 0.271463,1.387475 v 0.170921 q 0,0.110596 -0.07038,0.170921 -0.06032,0.06032 -0.160867,0.06032 H 25.77677 q 0,0.01005 0,0.04022 0,0.03016 0,0.05027 0.02011,0.412221 0.180975,0.774171 0.160867,0.351896 0.462492,0.573088 0.301625,0.221191 0.7239,0.221191 0.36195,0 0.60325,-0.110595 0.2413,-0.110596 0.392112,-0.2413 0.150813,-0.140759 0.201084,-0.211138 0.09049,-0.130704 0.140758,-0.150812 0.05027,-0.03016 0.160867,-0.03016 h 0.4826 q 0.100541,0 0.160866,0.06032 0.07038,0.05027 0.06032,0.150813 -0.01005,0.150812 -0.160866,0.372004 -0.150813,0.211137 -0.432329,0.422275 -0.281517,0.211137 -0.683684,0.351896 -0.402166,0.130704 -0.924983,0.130704 z M 25.77677,15.049762 h 2.754842 V 15.0196 q 0,-0.452438 -0.170921,-0.804333 -0.160867,-0.351896 -0.472546,-0.55298 -0.311679,-0.211137 -0.744008,-0.211137 -0.432329,0 -0.744008,0.211137 -0.301625,0.201084 -0.462492,0.55298 -0.160867,0.351895 -0.160867,0.804333 z"
id="path2866"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 30.34322,18.045904 q -0.08043,0 -0.150813,-0.06032 -0.06032,-0.06033 -0.06032,-0.150812 0,-0.03016 0.01006,-0.07038 0.02011,-0.05027 0.06032,-0.110595 l 1.749425,-2.282296 -1.63883,-2.161646 q -0.04022,-0.06032 -0.06032,-0.100542 -0.01005,-0.04022 -0.01005,-0.08043 0,-0.09049 0.06032,-0.150813 0.06032,-0.06033 0.150813,-0.06033 h 0.512762 q 0.110596,0 0.160867,0.06033 0.06032,0.05027 0.100542,0.100542 l 1.337204,1.739371 1.337204,-1.729317 q 0.04022,-0.05027 0.09049,-0.110596 0.06032,-0.06033 0.170921,-0.06033 h 0.492654 q 0.09049,0 0.150813,0.06033 0.06032,0.06033 0.06032,0.150813 0,0.04022 -0.02011,0.08043 -0.01006,0.04022 -0.05027,0.100542 l -1.658938,2.181754 1.749425,2.262188 q 0.04022,0.06033 0.05027,0.100541 0.02011,0.04022 0.02011,0.08043 0,0.09049 -0.06032,0.150812 -0.06032,0.06032 -0.150812,0.06032 h -0.532871 q -0.100542,0 -0.160867,-0.05027 -0.06032,-0.05027 -0.100541,-0.100541 l -1.417638,-1.839913 -1.417637,1.839913 q -0.04022,0.04022 -0.100542,0.100541 -0.05027,0.05027 -0.160867,0.05027 z"
id="path2868"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 36.245011,19.956196 q -0.100542,0 -0.170921,-0.07038 -0.06032,-0.06032 -0.06032,-0.160867 v -6.675967 q 0,-0.100541 0.06032,-0.160866 0.07038,-0.07038 0.170921,-0.07038 h 0.462491 q 0.100542,0 0.160867,0.07038 0.07038,0.06033 0.07038,0.160866 v 0.442384 q 0.2413,-0.331788 0.643467,-0.55298 0.402167,-0.221191 1.005417,-0.221191 0.563033,0 0.955146,0.191029 0.402166,0.191029 0.65352,0.522817 0.261409,0.331787 0.392113,0.754062 0.130704,0.422275 0.140758,0.904875 0.01005,0.160867 0.01005,0.341842 0,0.180975 -0.01005,0.341841 -0.01005,0.472546 -0.140758,0.904875 -0.130704,0.422275 -0.392113,0.754063 -0.251354,0.321733 -0.65352,0.522817 -0.392113,0.191029 -0.955146,0.191029 -0.583142,0 -0.985309,-0.211138 -0.392112,-0.221191 -0.643466,-0.542925 v 2.332567 q 0,0.100542 -0.06032,0.160867 -0.06033,0.07038 -0.170921,0.07038 z m 2.131483,-2.624138 q 0.522817,0 0.814388,-0.221191 0.301625,-0.231246 0.432329,-0.593196 0.130704,-0.372004 0.150812,-0.794279 0.01006,-0.291571 0,-0.583142 -0.02011,-0.422275 -0.150812,-0.784225 -0.130704,-0.372004 -0.432329,-0.593196 -0.291571,-0.231246 -0.814388,-0.231246 -0.492654,0 -0.804333,0.231246 -0.301625,0.231246 -0.452438,0.593196 -0.140758,0.351896 -0.160866,0.7239 -0.01006,0.160867 -0.01006,0.382058 0,0.221192 0.01006,0.392113 0.01005,0.351896 0.160866,0.693737 0.160867,0.341842 0.472546,0.563034 0.311679,0.221191 0.784225,0.221191 z"
id="path2870"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 42.227228,18.045904 q -0.100542,0 -0.170921,-0.06032 -0.06032,-0.07038 -0.06032,-0.170921 v -4.755621 q 0,-0.100541 0.06032,-0.17092 0.07038,-0.07038 0.170921,-0.07038 h 0.462492 q 0.100541,0 0.170921,0.07038 0.07038,0.07038 0.07038,0.17092 v 0.442384 q 0.201083,-0.341842 0.552979,-0.512763 0.351896,-0.170921 0.84455,-0.170921 h 0.402167 q 0.100541,0 0.160866,0.07038 0.06032,0.06033 0.06032,0.160866 v 0.412221 q 0,0.100542 -0.06032,0.160867 -0.06032,0.06033 -0.160866,0.06033 h -0.60325 q -0.542925,0 -0.854605,0.321733 -0.311679,0.311679 -0.311679,0.854604 v 2.955925 q 0,0.100542 -0.07038,0.170921 -0.07038,0.06032 -0.170921,0.06032 z"
id="path2872"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 47.799738,18.146446 q -1.035579,0 -1.648883,-0.633413 -0.613304,-0.643466 -0.673629,-1.749425 -0.01005,-0.130704 -0.01005,-0.331787 0,-0.211138 0.01005,-0.341842 0.04022,-0.713846 0.331787,-1.246717 0.291571,-0.542925 0.79428,-0.834495 0.512762,-0.291571 1.196445,-0.291571 0.764117,0 1.27688,0.321733 0.522816,0.321733 0.794279,0.914929 0.271462,0.593196 0.271462,1.387475 v 0.170921 q 0,0.110596 -0.07038,0.170921 -0.06033,0.06032 -0.160867,0.06032 h -3.478741 q 0,0.01005 0,0.04022 0,0.03016 0,0.05027 0.02011,0.412221 0.180975,0.774171 0.160866,0.351896 0.462491,0.573088 0.301625,0.221191 0.7239,0.221191 0.36195,0 0.60325,-0.110595 0.2413,-0.110596 0.392113,-0.2413 0.150812,-0.140759 0.201083,-0.211138 0.09049,-0.130704 0.140759,-0.150812 0.05027,-0.03016 0.160866,-0.03016 h 0.4826 q 0.100542,0 0.160867,0.06032 0.07038,0.05027 0.06032,0.150813 -0.01005,0.150812 -0.160867,0.372004 -0.150812,0.211137 -0.432329,0.422275 -0.281517,0.211137 -0.683683,0.351896 -0.402167,0.130704 -0.924984,0.130704 z m -1.367366,-3.096684 h 2.754841 V 15.0196 q 0,-0.452438 -0.17092,-0.804333 -0.160867,-0.351896 -0.472546,-0.55298 -0.311679,-0.211137 -0.744009,-0.211137 -0.432329,0 -0.744008,0.211137 -0.301625,0.201084 -0.462492,0.55298 -0.160866,0.351895 -0.160866,0.804333 z"
id="path2874"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 53.158603,18.146446 q -0.563033,0 -0.9652,-0.140759 -0.402167,-0.140758 -0.653521,-0.341841 -0.251354,-0.201084 -0.382058,-0.402167 -0.12065,-0.201083 -0.130704,-0.321733 -0.01005,-0.110596 0.07038,-0.170921 0.08043,-0.06032 0.160867,-0.06032 h 0.442383 q 0.06032,0 0.09049,0.02011 0.04022,0.01005 0.100542,0.08043 0.130704,0.140758 0.291571,0.281516 0.160867,0.140759 0.392113,0.231246 0.2413,0.09049 0.593195,0.09049 0.512763,0 0.84455,-0.19103 0.331788,-0.201083 0.331788,-0.583141 0,-0.251354 -0.140758,-0.402167 -0.130705,-0.150812 -0.4826,-0.271462 -0.341842,-0.12065 -0.945092,-0.251355 -0.60325,-0.140758 -0.955146,-0.341841 -0.351896,-0.211138 -0.502708,-0.492654 -0.150813,-0.291571 -0.150813,-0.653521 0,-0.372004 0.221192,-0.713846 0.221191,-0.351896 0.643467,-0.573088 0.432329,-0.221191 1.075795,-0.221191 0.522817,0 0.894821,0.130704 0.372004,0.130704 0.613304,0.331787 0.2413,0.19103 0.36195,0.382059 0.12065,0.191029 0.130705,0.321733 0.01005,0.100542 -0.06033,0.170921 -0.07038,0.06033 -0.160867,0.06033 h -0.422275 q -0.07038,0 -0.12065,-0.03016 -0.04022,-0.03016 -0.08043,-0.07038 -0.100542,-0.130704 -0.2413,-0.261408 -0.130705,-0.130704 -0.351896,-0.211138 -0.211138,-0.09049 -0.563034,-0.09049 -0.502708,0 -0.754062,0.211137 -0.251354,0.211138 -0.251354,0.532871 0,0.191029 0.110596,0.341842 0.110595,0.150812 0.422275,0.271462 0.311679,0.12065 0.924983,0.261409 0.663575,0.130704 1.045633,0.351896 0.382059,0.221191 0.542925,0.512762 0.160867,0.291571 0.160867,0.673629 0,0.422275 -0.251354,0.774171 -0.2413,0.351896 -0.7239,0.563033 -0.472546,0.201084 -1.176338,0.201084 z"
id="path2876"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 58.286212,18.146446 q -0.563033,0 -0.9652,-0.140759 -0.402166,-0.140758 -0.65352,-0.341841 -0.251355,-0.201084 -0.382059,-0.402167 -0.12065,-0.201083 -0.130704,-0.321733 -0.01005,-0.110596 0.07038,-0.170921 0.08043,-0.06032 0.160867,-0.06032 h 0.442383 q 0.06032,0 0.09049,0.02011 0.04022,0.01005 0.100541,0.08043 0.130705,0.140758 0.291571,0.281516 0.160867,0.140759 0.392113,0.231246 0.2413,0.09049 0.593196,0.09049 0.512762,0 0.84455,-0.19103 0.331787,-0.201083 0.331787,-0.583141 0,-0.251354 -0.140758,-0.402167 -0.130704,-0.150812 -0.4826,-0.271462 -0.341842,-0.12065 -0.945092,-0.251355 -0.60325,-0.140758 -0.955146,-0.341841 -0.351896,-0.211138 -0.502708,-0.492654 -0.150813,-0.291571 -0.150813,-0.653521 0,-0.372004 0.221192,-0.713846 0.221192,-0.351896 0.643467,-0.573088 0.432329,-0.221191 1.075796,-0.221191 0.522816,0 0.89482,0.130704 0.372005,0.130704 0.613305,0.331787 0.2413,0.19103 0.36195,0.382059 0.12065,0.191029 0.130704,0.321733 0.01005,0.100542 -0.06033,0.170921 -0.07038,0.06033 -0.160867,0.06033 h -0.422275 q -0.07038,0 -0.12065,-0.03016 -0.04022,-0.03016 -0.08043,-0.07038 -0.100542,-0.130704 -0.2413,-0.261408 -0.130704,-0.130704 -0.351896,-0.211138 -0.211138,-0.09049 -0.563033,-0.09049 -0.502709,0 -0.754063,0.211137 -0.251354,0.211138 -0.251354,0.532871 0,0.191029 0.110596,0.341842 0.110596,0.150812 0.422275,0.271462 0.311679,0.12065 0.924983,0.261409 0.663575,0.130704 1.045633,0.351896 0.382059,0.221191 0.542925,0.512762 0.160867,0.291571 0.160867,0.673629 0,0.422275 -0.251354,0.774171 -0.2413,0.351896 -0.7239,0.563033 -0.472546,0.201084 -1.176338,0.201084 z"
id="path2878"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
<g
id="g1018"
style="display:inline">
<g
id="g4374"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="opacity:1;fill:#ff3e00;fill-opacity:1;stroke:#ff3e00;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect21526"
width="46.566666"
height="46.566666"
x="11.641667"
y="10.583333" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000"
id="rect52610-6"
width="38.099995"
height="2.1166663"
x="20.108339"
y="29.633333" />
</g>
<g
aria-label="svelte"
id="text24132"
style="font-size:13.7583px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(-0.11704721,3.6255731)">
<path
d="m 22.379335,23.475161 q -0.770465,0 -1.320797,-0.192616 -0.550332,-0.192617 -0.894289,-0.467783 -0.343958,-0.275166 -0.522815,-0.550332 -0.1651,-0.275166 -0.178858,-0.440265 -0.01376,-0.151341 0.09631,-0.233891 0.110066,-0.08255 0.220133,-0.08255 h 0.605365 q 0.08255,0 0.123824,0.02752 0.05503,0.01376 0.137583,0.110066 0.178858,0.192616 0.398991,0.385232 0.220133,0.192617 0.536574,0.316441 0.330199,0.123825 0.81174,0.123825 0.701673,0 1.155697,-0.261408 0.454024,-0.275166 0.454024,-0.797981 0,-0.343958 -0.192617,-0.550332 -0.178858,-0.206375 -0.660398,-0.371474 -0.467782,-0.1651 -1.29328,-0.343958 -0.825498,-0.192616 -1.307039,-0.467782 -0.48154,-0.288924 -0.687915,-0.674157 -0.206374,-0.39899 -0.206374,-0.894289 0,-0.509057 0.302682,-0.976839 0.302683,-0.481541 0.880532,-0.784223 0.591607,-0.302683 1.472138,-0.302683 0.715431,0 1.224488,0.178858 0.509057,0.178858 0.839257,0.454024 0.330199,0.261407 0.495298,0.522815 0.1651,0.261408 0.178858,0.440266 0.01376,0.137583 -0.08255,0.233891 -0.09631,0.08255 -0.220133,0.08255 h -0.577849 q -0.09631,0 -0.165099,-0.04127 -0.05503,-0.04127 -0.110067,-0.09631 -0.137583,-0.178858 -0.330199,-0.357716 -0.178858,-0.178858 -0.481541,-0.288925 -0.288924,-0.123824 -0.770464,-0.123824 -0.687915,0 -1.031873,0.288924 -0.343957,0.288924 -0.343957,0.72919 0,0.261408 0.151341,0.467782 0.151341,0.206375 0.577849,0.371474 0.426507,0.1651 1.265763,0.357716 0.908048,0.178858 1.430863,0.481541 0.522816,0.302682 0.742949,0.701673 0.220132,0.398991 0.220132,0.921806 0,0.577849 -0.343957,1.059389 -0.330199,0.481541 -0.990598,0.770465 -0.64664,0.275166 -1.609721,0.275166 z"
id="path977"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 29.165596,23.337578 q -0.206374,0 -0.330199,-0.09631 -0.110066,-0.09631 -0.178858,-0.24765 l -2.545285,-6.383851 q -0.02752,-0.06879 -0.02752,-0.137583 0,-0.123824 0.08255,-0.206374 0.08255,-0.08255 0.206375,-0.08255 h 0.660398 q 0.151341,0 0.233891,0.08255 0.08255,0.08255 0.09631,0.151341 l 2.10502,5.42077 2.091262,-5.42077 q 0.02752,-0.06879 0.09631,-0.151341 0.08255,-0.08255 0.233891,-0.08255 h 0.674156 q 0.110067,0 0.192617,0.08255 0.09631,0.08255 0.09631,0.206374 0,0.06879 -0.02752,0.137583 L 30.26626,22.99362 q -0.05503,0.151342 -0.178858,0.24765 -0.110066,0.09631 -0.330199,0.09631 z"
id="path979"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 36.889984,23.475161 q -1.417105,0 -2.256361,-0.866773 -0.839257,-0.880531 -0.921807,-2.393944 -0.01376,-0.178858 -0.01376,-0.454024 0,-0.288924 0.01376,-0.467782 0.05503,-0.97684 0.454024,-1.70603 0.398991,-0.742948 1.086906,-1.141938 0.701673,-0.398991 1.637238,-0.398991 1.045631,0 1.747304,0.440266 0.715431,0.440265 1.086906,1.252005 0.371474,0.811739 0.371474,1.898645 v 0.233891 q 0,0.151342 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -4.760372 q 0,0.01376 0,0.05503 0,0.04128 0,0.06879 0.02752,0.56409 0.247649,1.059389 0.220133,0.48154 0.632882,0.784223 0.412749,0.302683 0.990598,0.302683 0.495299,0 0.825498,-0.151342 0.330199,-0.151341 0.536573,-0.330199 0.206375,-0.192616 0.275166,-0.288924 0.123825,-0.178858 0.192617,-0.206375 0.06879,-0.04127 0.220132,-0.04127 h 0.660399 q 0.137583,0 0.220133,0.08255 0.09631,0.06879 0.08255,0.206375 -0.01376,0.206374 -0.220132,0.509057 -0.206375,0.288924 -0.591607,0.577848 -0.385233,0.288925 -0.935565,0.481541 -0.550332,0.178858 -1.265763,0.178858 z m -1.871129,-4.237557 h 3.769774 v -0.04127 q 0,-0.619124 -0.233891,-1.100664 -0.220133,-0.481541 -0.64664,-0.756707 -0.426507,-0.288924 -1.018114,-0.288924 -0.591607,0 -1.018114,0.288924 -0.412749,0.275166 -0.632882,0.756707 -0.220133,0.48154 -0.220133,1.100664 z"
id="path981"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 42.076855,23.337578 q -0.137583,0 -0.233891,-0.08255 -0.08255,-0.09631 -0.08255,-0.233891 v -9.135511 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 0.64664 q 0.151342,0 0.233891,0.09631 0.08255,0.08255 0.08255,0.220133 v 9.135511 q 0,0.137583 -0.08255,0.233891 -0.08255,0.08255 -0.233891,0.08255 z"
id="path983"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 47.882832,23.337578 q -0.770465,0 -1.252006,-0.288924 -0.48154,-0.302683 -0.701673,-0.839257 -0.220133,-0.550332 -0.220133,-1.29328 v -3.632191 h -1.073147 q -0.137583,0 -0.233891,-0.08255 -0.08255,-0.09631 -0.08255,-0.233891 v -0.467782 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 1.073147 v -2.297636 q 0,-0.137583 0.08255,-0.220133 0.09631,-0.09631 0.233891,-0.09631 h 0.64664 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 2.297636 h 1.706029 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 0.467782 q 0,0.137583 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -1.706029 v 3.535883 q 0,0.64664 0.220133,1.018114 0.220133,0.371474 0.784223,0.371474 h 0.839256 q 0.137583,0 0.220133,0.09631 0.09631,0.08255 0.09631,0.220133 v 0.495299 q 0,0.137583 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 z"
id="path985"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 53.317345,23.475161 q -1.417105,0 -2.256361,-0.866773 -0.839256,-0.880531 -0.921806,-2.393944 -0.01376,-0.178858 -0.01376,-0.454024 0,-0.288924 0.01376,-0.467782 0.05503,-0.97684 0.454024,-1.70603 0.398991,-0.742948 1.086906,-1.141938 0.701673,-0.398991 1.637237,-0.398991 1.045631,0 1.747304,0.440266 0.715432,0.440265 1.086906,1.252005 0.371474,0.811739 0.371474,1.898645 v 0.233891 q 0,0.151342 -0.09631,0.233891 -0.08255,0.08255 -0.220133,0.08255 h -4.760372 q 0,0.01376 0,0.05503 0,0.04128 0,0.06879 0.02752,0.56409 0.24765,1.059389 0.220133,0.48154 0.632882,0.784223 0.412749,0.302683 0.990597,0.302683 0.495299,0 0.825498,-0.151342 0.330199,-0.151341 0.536574,-0.330199 0.206374,-0.192616 0.275166,-0.288924 0.123825,-0.178858 0.192616,-0.206375 0.06879,-0.04127 0.220133,-0.04127 h 0.660398 q 0.137583,0 0.220133,0.08255 0.09631,0.06879 0.08255,0.206375 -0.01376,0.206374 -0.220133,0.509057 -0.206374,0.288924 -0.591607,0.577848 -0.385232,0.288925 -0.935564,0.481541 -0.550332,0.178858 -1.265764,0.178858 z m -1.871129,-4.237557 h 3.769775 v -0.04127 q 0,-0.619124 -0.233891,-1.100664 -0.220133,-0.481541 -0.646641,-0.756707 -0.426507,-0.288924 -1.018114,-0.288924 -0.591607,0 -1.018114,0.288924 -0.412749,0.275166 -0.632882,0.756707 -0.220133,0.48154 -0.220133,1.100664 z"
id="path987"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
<g
id="g975"
style="display:inline">
<g
id="g4370"
style="display:inline"
transform="translate(-1.0583342)">
<rect
style="display:inline;opacity:1;fill:#ff0d2f;fill-opacity:1;stroke:#ff0d2f;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect26826"
width="46.566666"
height="46.566669"
x="3.1750009"
y="19.049999" />
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#fffdff;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;stop-color:#000000;stop-opacity:1"
id="rect52610"
width="38.100002"
height="2.116667"
x="11.641667"
y="38.099998" />
</g>
<g
aria-label="swaf"
id="text15483"
style="font-size:16.9333px;line-height:1.25;font-family:Rubik;-inkscape-font-specification:Rubik;text-align:end;text-anchor:end;display:none;fill:#fefefe;stroke-width:0.529167"
transform="translate(-5.0785949,7.0535064)">
<path
d="m 19.182091,28.269415 q -0.948264,0 -1.625596,-0.237066 -0.677332,-0.237066 -1.100665,-0.575732 -0.423332,-0.338666 -0.643465,-0.677332 -0.2032,-0.338666 -0.220133,-0.541866 -0.01693,-0.186266 0.118533,-0.287866 0.135466,-0.1016 0.270933,-0.1016 h 0.745065 q 0.1016,0 0.1524,0.03387 0.06773,0.01693 0.169333,0.135466 0.220132,0.237067 0.491065,0.474133 0.270933,0.237066 0.660399,0.389466 0.406399,0.152399 0.999065,0.152399 0.863598,0 1.422397,-0.321732 0.558799,-0.338666 0.558799,-0.982132 0,-0.423332 -0.237066,-0.677332 -0.220133,-0.253999 -0.812799,-0.457199 -0.575732,-0.203199 -1.59173,-0.423332 -1.015998,-0.237066 -1.608664,-0.575732 -0.592665,-0.3556 -0.846665,-0.829732 -0.253999,-0.491066 -0.253999,-1.100665 0,-0.626532 0.372533,-1.202264 0.372532,-0.592665 1.083731,-0.965198 0.728132,-0.372533 1.811863,-0.372533 0.880531,0 1.507064,0.220133 0.626532,0.220133 1.032931,0.558799 0.406399,0.321733 0.609599,0.643466 0.203199,0.321732 0.220133,0.541865 0.01693,0.169333 -0.1016,0.287866 -0.118533,0.1016 -0.270933,0.1016 H 21.38342 q -0.118533,0 -0.203199,-0.0508 -0.06773,-0.0508 -0.135467,-0.118533 -0.169333,-0.220133 -0.406399,-0.440266 -0.220133,-0.220133 -0.592665,-0.355599 -0.3556,-0.1524 -0.948265,-0.1524 -0.846665,0 -1.269998,0.3556 -0.423332,0.355599 -0.423332,0.897464 0,0.321733 0.186266,0.575733 0.186266,0.253999 0.711199,0.457199 0.524932,0.203199 1.557863,0.440266 1.117598,0.220132 1.761064,0.592665 0.643465,0.372533 0.914398,0.863598 0.270933,0.491066 0.270933,1.134531 0,0.711199 -0.423333,1.303865 -0.406399,0.592665 -1.219198,0.948264 -0.795865,0.338666 -1.981196,0.338666 z"
id="path841"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 26.874013,28.100082 q -0.220133,0 -0.338666,-0.101599 -0.118533,-0.118534 -0.2032,-0.321733 l -2.404528,-7.857051 q -0.03387,-0.08467 -0.03387,-0.169333 0,-0.1524 0.1016,-0.254 0.118533,-0.1016 0.253999,-0.1016 h 0.745065 q 0.186267,0 0.287867,0.1016 0.101599,0.1016 0.135466,0.186266 l 1.879596,6.383855 2.015063,-6.316121 q 0.03387,-0.118533 0.135466,-0.237067 0.118533,-0.118533 0.338666,-0.118533 h 0.575733 q 0.220132,0 0.338666,0.118533 0.118533,0.118534 0.135466,0.237067 l 2.015063,6.316121 1.879596,-6.383855 q 0.01693,-0.08467 0.118533,-0.186266 0.1016,-0.1016 0.287866,-0.1016 h 0.745065 q 0.1524,0 0.254,0.1016 0.1016,0.1016 0.1016,0.254 0,0.08467 -0.03387,0.169333 l -2.404529,7.857051 q -0.0508,0.203199 -0.186266,0.321733 -0.118533,0.101599 -0.355599,0.101599 h -0.660399 q -0.220133,0 -0.372532,-0.101599 -0.135467,-0.118534 -0.186267,-0.321733 L 30.0744,21.614628 28.110138,27.67675 q -0.06773,0.203199 -0.2032,0.321733 -0.135466,0.101599 -0.372533,0.101599 z"
id="path843"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 40.316868,28.269415 q -0.846665,0 -1.540931,-0.338666 -0.694265,-0.338666 -1.117597,-0.914398 -0.423333,-0.575732 -0.423333,-1.303864 0,-1.168398 0.948265,-1.862663 0.948265,-0.694265 2.472262,-0.914398 l 2.523061,-0.355599 v -0.491066 q 0,-0.812799 -0.474132,-1.269998 -0.457199,-0.457199 -1.507064,-0.457199 -0.761998,0 -1.236131,0.3048 -0.457199,0.304799 -0.643465,0.778931 -0.1016,0.254 -0.355599,0.254 h -0.761999 q -0.186266,0 -0.287866,-0.1016 -0.08467,-0.118533 -0.08467,-0.270933 0,-0.253999 0.186266,-0.626532 0.203199,-0.372532 0.609599,-0.728132 0.406399,-0.355599 1.032931,-0.592665 0.643465,-0.254 1.557864,-0.254 1.015998,0 1.710263,0.270933 0.694265,0.254 1.083731,0.694265 0.406399,0.440266 0.575732,0.999065 0.186267,0.558799 0.186267,1.134531 v 5.486389 q 0,0.169333 -0.118534,0.287867 -0.101599,0.101599 -0.270932,0.101599 h -0.778932 q -0.186266,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 v -0.728131 q -0.220133,0.304799 -0.592666,0.609598 -0.372532,0.287866 -0.931331,0.491066 -0.558799,0.186266 -1.371597,0.186266 z m 0.355599,-1.269997 q 0.694265,0 1.269997,-0.287866 0.575733,-0.3048 0.897465,-0.931332 0.338666,-0.626532 0.338666,-1.574797 v -0.474132 l -1.964262,0.287866 q -1.202265,0.169333 -1.811864,0.575732 -0.609598,0.389466 -0.609598,0.999065 0,0.474132 0.270932,0.795865 0.287867,0.304799 0.711199,0.457199 0.440266,0.1524 0.897465,0.1524 z"
id="path845"
style="fill:#ffffff;fill-opacity:1" />
<path
d="m 48.397167,28.100082 q -0.169333,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 V 20.64943 h -1.43933 q -0.169333,0 -0.287866,-0.101599 -0.1016,-0.118534 -0.1016,-0.287867 v -0.575732 q 0,-0.169333 0.1016,-0.270933 0.118533,-0.118533 0.287866,-0.118533 h 1.43933 v -0.846665 q 0,-0.863598 0.287867,-1.507063 0.287866,-0.660399 0.914398,-1.015998 0.643465,-0.3556 1.710263,-0.3556 h 1.015998 q 0.169333,0 0.270933,0.118533 0.1016,0.1016 0.1016,0.270933 v 0.575732 q 0,0.169333 -0.1016,0.287867 -0.1016,0.101599 -0.270933,0.101599 h -0.982131 q -0.795865,0 -1.083732,0.423333 -0.287866,0.406399 -0.287866,1.185331 v 0.761998 h 2.184396 q 0.169333,0 0.270933,0.118533 0.1016,0.1016 0.1016,0.270933 v 0.575732 q 0,0.169333 -0.1016,0.287867 -0.1016,0.101599 -0.270933,0.101599 h -2.184396 v 7.061186 q 0,0.169333 -0.118533,0.287867 -0.1016,0.101599 -0.270933,0.101599 z"
id="path847"
style="fill:#ffffff;fill-opacity:1" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 29 KiB

3
src/assets/package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@ -0,0 +1 @@
@import "data-table";

View File

@ -0,0 +1,81 @@
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cceyI9tScg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8ccezI9tScg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cce9I9s.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@ -0,0 +1,139 @@
@import "vars";
@mixin darkMode() {
@media (prefers-color-scheme: dark) {
@content;
}
}
@mixin surface($shadowStrength: 0) {
background-color: var(--surface);
color: var(--on-surface);
a {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
:global(a) {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
// Buttons
button, .button {
&:not(.bold) {
--background-color: var(--surface);
&:hover::after {
--background-color: var(--on-surface);
:global(&) {
--background-color: var(--on-surface);
}
}
}
}
// States modifiers
.primary:not(.bold) {
--color: var(--primary-on-surface);
--background-color: var(--surface);
}
.info:not(.bold) {
--color: var(--info-on-surface);
--background-color: var(--surface);
}
.success:not(.bold) {
--color: var(--success-on-surface);
--background-color: var(--surface);
}
.warning:not(.bold) {
--color: var(--warning-on-surface);
--background-color: var(--surface);
}
.error:not(.bold), .danger:not(.bold) {
--color: var(--error-on-surface);
--background-color: var(--surface);
}
@if ($shadowStrength > 0) {
box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045;
}
}
@mixin subsurface($shadowStrength: 0) {
@include surface($shadowStrength);
background-color: var(--subsurface);
color: var(--on-subsurface);
}
// --- Responsivity ---
@mixin mobile-le {
@media (max-width: $mobileThreshold - 1px) {
@content;
}
}
@mixin mobile-ge {
@content;
}
@mixin medium-le {
@media (max-width: $desktopThreshold - 1px) {
@content;
}
}
@mixin medium-ge {
@media (min-width: $mobileThreshold) {
@content;
}
}
@mixin large-le {
@content;
}
@mixin large-ge {
@media (min-width: $desktopThreshold) {
@content;
}
}
@mixin container {
width: 100%;
max-width: 100%;
padding-left: 8px;
padding-right: 8px;
@include medium-ge {
margin-left: auto;
margin-right: auto;
padding-left: 16px;
padding-right: 16px;
}
@include large-ge {
width: $desktopThreshold;
}
}
@mixin fake-hide {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}

116
src/assets/scss/_vars.scss Normal file
View File

@ -0,0 +1,116 @@
//
// --- Color palette ---
//
$onLight: #222;
$onDark: #eee;
// Primary
$primary: #0046af;
$primaryLight: lighten($primary, 10%);
$onPrimary: $onDark;
$primaryOnBackground: $primary;
$primaryLightOnBackground: $primaryLight;
$primaryOnSurface: $primary;
$primaryLightOnSurface: $primaryLight;
$primaryDarkMode: #0054c9;
$primaryLightDarkMode: lighten($primaryDarkMode, 23%);
$onPrimaryDarkMode: $onDark;
$primaryOnBackgroundDarkMode: lighten($primaryDarkMode, 20%);
$primaryLightOnBackgroundDarkMode: $primaryLightDarkMode;
$primaryOnSurfaceDarkMode: lighten($primaryDarkMode, 29%);
$primaryLightOnSurfaceDarkMode: lighten($primaryOnSurfaceDarkMode, 15%);
// Secondary
$secondary: #f21170;
$onSecondary: $onLight;
$secondaryDarkMode: $secondary;
$onSecondaryDarkMode: $onSecondary;
// Background
$backgroundBase: #eee;
$background: mix($backgroundBase, $primary, 98%);
$onBackground: $onLight;
$backgroundBaseDarkMode: #222;
$backgroundDarkMode: mix($backgroundBaseDarkMode, $primaryDarkMode, 98%);
$onBackgroundDarkMode: $onDark;
// Surface
$surface: lighten($background, 6%);
$onSurface: $onLight;
$surfaceDarkMode: darken($backgroundDarkMode, 4.5%);
$onSurfaceDarkMode: $onDark;
// Subsurface
$subsurface: darken($surface, 3%);
$onSubsurface: $onLight;
$subsurfaceDarkMode: darken($surfaceDarkMode, 3%);
$onSubsurfaceDarkMode: $onDark;
// Input
$input: darken($surface, 5%);
$onInput: $onLight;
$inputDarkMode: darken($surfaceDarkMode, 5%);
$onInputDarkMode: $onDark;
//
// --- Layout ---
//
$header: $surface;
$headerDarkMode: $surfaceDarkMode;
$headerContainer: true;
$headerHeight: 72px;
$footer: transparent;
//
// --- State palette ---
//
$info: #4499ff;
$onInfo: darken($info, 50%);
$infoOnBackground: darken($info, 20%);
$infoOnSurface: darken($info, 20%);
$infoDarkMode: darken($info, 40%);
$onInfoDarkMode: lighten($info, 20%);
$infoOnBackgroundDarkMode: $info;
$infoOnSurfaceDarkMode: $info;
$success: #55ff55;
$onSuccess: darken($success, 45%);
$successOnBackground: darken($success, 45%);
$successOnSurface: darken($success, 45%);
$successDarkMode: darken($success, 45%);
$onSuccessDarkMode: lighten($success, 20%);
$successOnBackgroundDarkMode: $success;
$successOnSurfaceDarkMode: $success;
$warning: #ffcc00;
$onWarning: darken($warning, 30%);
$warningOnBackground: darken($warning, 25%);
$warningOnSurface: darken($warning, 25%);
$warningDarkMode: darken($warning, 30%);
$onWarningDarkMode: lighten($warning, 20%);
$warningOnBackgroundDarkMode: $warning;
$warningOnSurfaceDarkMode: $warning;
$error: #ff0000;
$onError: darken($error, 40%);
$errorOnBackground: darken($error, 10%);
$errorOnSurface: darken($error, 10%);
$errorDarkMode: darken($error, 30%);
$onErrorDarkMode: lighten($error, 20%);
$errorOnBackgroundDarkMode: lighten($error, 15%);
$errorOnSurfaceDarkMode: lighten($error, 3%);
//
// --- Responsivity ---
//
$mobileThreshold: 632px;
$desktopThreshold: 940px;

276
src/assets/scss/base.scss Normal file
View File

@ -0,0 +1,276 @@
@import "vars";
@import "helpers";
@import "fonts";
@import "../../../node_modules/normalize.css/normalize";
// --- Css variables, dark mode ---
:root {
// Primary
--primary: #{$primary};
--primary-light: #{$primaryLight};
--on-primary: #{$onPrimary};
--primary-on-background: #{$primaryOnBackground};
--primary-light-on-background: #{$primaryLightOnBackground};
--primary-on-surface: #{$primaryOnSurface};
--primary-light-on-surface: #{$primaryLightOnSurface};
// Secondary
--secondary: #{$secondary};
--on-secondary: #{$onSecondary};
// Background
--background: #{$background};
--on-background: #{$onBackground};
// Surface
--surface: #{$surface};
--on-surface: #{$onSurface};
// Subsurface
--subsurface: #{$subsurface};
--on-subsurface: #{$onSubsurface};
// Input
--input: #{$input};
--on-input: #{$onInput};
// States
--info: #{$info};
--success: #{$success};
--warning: #{$warning};
--error: #{$error};
// States text
--on-info: #{$onInfo};
--on-success: #{$onSuccess};
--on-warning: #{$onWarning};
--on-error: #{$onError};
// States text on background
--info-on-background: #{$infoOnBackground};
--success-on-background: #{$successOnBackground};
--warning-on-background: #{$warningOnBackground};
--error-on-background: #{$errorOnBackground};
// States text on surface
--info-on-surface: #{$infoOnSurface};
--success-on-surface: #{$successOnSurface};
--warning-on-surface: #{$warningOnSurface};
--error-on-surface: #{$errorOnSurface};
@include darkMode {
// Primary
--primary: #{$primaryDarkMode};
--primary-light: #{$primaryLightDarkMode};
--on-primary: #{$onPrimaryDarkMode};
--primary-on-background: #{$primaryOnBackgroundDarkMode};
--primary-light-on-background: #{$primaryLightOnBackgroundDarkMode};
--primary-on-surface: #{$primaryOnSurfaceDarkMode};
--primary-light-on-surface: #{$primaryLightOnSurfaceDarkMode};
// Secondary
--secondary: #{$secondaryDarkMode};
--on-secondary: #{$onSecondaryDarkMode};
// Background
--background: #{$backgroundDarkMode};
--on-background: #{$onBackgroundDarkMode};
// Surface
--surface: #{$surfaceDarkMode};
--on-surface: #{$onSurfaceDarkMode};
// Subsurface
--subsurface: #{$subsurfaceDarkMode};
--on-subsurface: #{$onSubsurfaceDarkMode};
// Input
--input: #{$inputDarkMode};
--on-input: #{$onInputDarkMode};
// States
--info: #{$infoDarkMode};
--success: #{$successDarkMode};
--warning: #{$warningDarkMode};
--error: #{$errorDarkMode};
// States text
--on-info: #{$onInfoDarkMode};
--on-success: #{$onSuccessDarkMode};
--on-warning: #{$onWarningDarkMode};
--on-error: #{$onErrorDarkMode};
// States text on background
--info-on-background: #{$infoOnBackgroundDarkMode};
--success-on-background: #{$successOnBackgroundDarkMode};
--warning-on-background: #{$warningOnBackgroundDarkMode};
--error-on-background: #{$errorOnBackgroundDarkMode};
// States text on surface
--info-on-surface: #{$infoOnSurfaceDarkMode};
--success-on-surface: #{$successOnSurfaceDarkMode};
--warning-on-surface: #{$warningOnSurfaceDarkMode};
--error-on-surface: #{$errorOnSurfaceDarkMode};
}
--color: var(--on-background);
--background-color: var(--background);
}
:focus-visible,
button:focus-visible,
[type="button"]:focus-visible,
[type="reset"]:focus-visible,
[type="submit"]:focus-visible {
outline: 3px solid var(--primary-light);
outline-offset: 2px;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
color: var(--color);
background-color: var(--background-color);
a {
color: var(--primary-on-background);
&:hover {
color: var(--primary-light-on-background);
}
}
}
h1 {
text-align: center;
}
a {
text-decoration: none;
.icon.lucide-external-link {
--icon-size: 16px;
margin-left: 4px;
margin-top: -3px;
}
}
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);
&.bold {
--color: var(--on-primary);
--background-color: var(--primary);
}
}
.info {
--color: var(--info-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-info);
--background-color: var(--info);
}
}
.success {
--color: var(--success-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-success);
--background-color: var(--success);
}
}
.warning {
--color: var(--warning-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-warning);
--background-color: var(--warning);
}
}
.error, .danger {
--color: var(--error-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-error);
--background-color: var(--error);
}
}
button, .button {
position: relative;
display: inline-flex;
margin: 8px;
padding: 12px 16px;
border: 1px solid var(--color);
color: var(--color);
background-color: var(--background-color);
cursor: pointer;
text-transform: uppercase;
font-size: 16px;
line-height: 16px;
font-weight: bolder;
border-radius: 5px;
overflow: hidden;
&.bold {
border: 0;
}
.icon {
--icon-size: 16px;
margin-right: 8px;
}
.icon.last {
margin-right: 0;
margin-left: 8px;
}
&:hover::after:not([disabled]) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--on-background);
opacity: 0.2;
}
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
}

View File

@ -0,0 +1,71 @@
.data-table {
width: 100%;
text-align: left;
border-collapse: collapse;
th, td {
padding: 8px;
}
th {
border-bottom: 1px solid #39434a;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.03);
}
tr:hover {
background-color: rgba(255, 255, 255, 0.09);
}
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 {
overflow-x: auto;
max-width: 100%;
}

View File

@ -0,0 +1,26 @@
@import "vars";
@import "helpers";
@import "base";
@import "panel";
@import "components";
html {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100%;
margin: 0;
font-family: "Nunito Sans", sans-serif;
font-size: 16px;
background-color: var(--background);
color: var(--on-background);
> * {
flex-shrink: 0;
}
}

View File

@ -0,0 +1,53 @@
@import "vars";
@import "helpers";
.panel {
position: relative;
margin: 16px 0 48px;
padding: 8px;
border-radius: 5px;
@include surface;
.panel {
@include subsurface;
}
p {
margin: 16px 8px;
}
> .icon:first-child {
--icon-size: 24px;
position: absolute;
opacity: 0.2;
top: 8px;
left: 8px;
}
> h1, > h2, > h3, > h4, > h5, > h6 {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
text-align: center;
margin-top: 4px;
font-size: 24px;
line-height: 1;
.icon {
--icon-size: 24px;
margin: 0 16px 0 0;
opacity: 0.2;
}
&::after {
content: "";
flex: 1;
margin: 0 16px;
height: 0;
border-bottom: 1px solid var(--on-surface);
opacity: 0.2;
}
}
}

46
src/assets/scss/tip.scss Normal file
View File

@ -0,0 +1,46 @@
@import "vars";
@mixin tip {
position: relative;
.tip {
visibility: hidden;
position: absolute;
z-index: 10000;
pointer-events: none;
display: block;
width: max-content;
height: 30px;
padding: 4px 8px;
line-height: 22px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
text-align: center;
font-size: 18px;
color: var(--on-surface);
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: var(--surface);
border-radius: 5px;
text-transform: initial;
font-weight: initial;
&.top {
top: auto;
bottom: calc(100% + 8px);
}
}
&:hover, &:active {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
}
}

View File

@ -0,0 +1,38 @@
export default class WebsocketClient {
/**
* @param websocketUrl
* @param listener
* @param reconnectOnCloseAfter time to reconnect after connection fail in ms. -1 to not reconnect automatically.
* @param checkFunction
*/
public constructor(
private readonly websocketUrl: string,
private readonly listener: (websocket: WebSocket, e: MessageEvent) => void,
private readonly reconnectOnCloseAfter: number = 1000,
) {
}
public run(): void {
const websocket = new WebSocket(this.websocketUrl);
websocket.onopen = () => {
console.debug('Websocket connected');
websocket.send(document.cookie);
};
websocket.onmessage = (e) => {
this.listener(websocket, e);
};
websocket.onerror = (e) => {
console.error('Websocket error', e);
};
websocket.onclose = (e) => {
console.debug('Websocket closed', e.code, e.reason);
if (this.reconnectOnCloseAfter >= 0) {
setTimeout(() => {
this.run();
}, this.reconnectOnCloseAfter);
}
};
}
}

View File

@ -0,0 +1,25 @@
export function dateToDatetimeLocal(date: Date): string {
function ten(i: number) {
return (i < 10 ? '0' : '') + i;
}
const YYYY = date.getFullYear();
const MM = ten(date.getMonth() + 1);
const DD = ten(date.getDate());
const HH = ten(date.getHours());
const II = ten(date.getMinutes());
const SS = ten(date.getSeconds());
return YYYY + '-' + MM + '-' + DD + 'T' +
HH + ':' + II + ':' + SS;
}
export const dateToIsoString = (function (BST) {
// BST should not be present as UTC time
if (new Date(BST).toISOString().slice(0, 16) === BST) {
return (date: Date): string => {
return new Date(date.getTime() + date.getTimezoneOffset() * 60000)
.toISOString();
};
} else {
return (date: Date) => date.toISOString();
}
}('2006-06-06T06:06'));

17
src/assets/ts/icons.ts Normal file
View File

@ -0,0 +1,17 @@
import {createIcons, icons} from "lucide";
import {toLucideIconsPascalCase} from "../../common/StringUtils.js";
let hasAlreadyReplacedIcons = false;
export function replaceIcons(once: boolean): void {
if (!once || !hasAlreadyReplacedIcons) {
console.log('Create icons...');
createIcons({icons});
hasAlreadyReplacedIcons = true;
}
}
export function isLucideIcon(iconName: string): boolean {
return Object.keys(icons).indexOf(toLucideIconsPascalCase(iconName)) >= 0;
}

3
src/assets/ts/stores.ts Normal file
View File

@ -0,0 +1,3 @@
import {writable} from "svelte/store";
export const locals = writable<Record<string, unknown>>({});

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"include": [
"./**/*"
]
}

View File

@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"baseUrl": "../../../intermediates/assets",
"rootDir": "../../../intermediates/assets/ts-source",
"sourceRoot": "../../../intermediates/assets/ts-source",
"outDir": "../../../intermediates/assets/ts",
"declaration": false,
"typeRoots": [],
"resolveJsonModule": false,
"lib": [
"es2020",
"DOM"
]
},
"include": [
"../../../intermediates/assets/ts-source/**/*"
],
"references": [
{
"path": "../../common"
}
]
}

View File

@ -0,0 +1,49 @@
<script>
import Icon from "./utils/Icon.svelte";
</script>
<p style="display: flex; flex-direction: column; align-items: start">
<button>Default button</button>
<button class="bold">Default bold button</button>
<button class="primary">
<Icon name="square"/>
Primary button
</button>
<button class="primary bold">
<Icon name="square"/>
Primary bold button
</button>
<button class="success">
<Icon name="check"/>
Success button
</button>
<button class="success bold">
<Icon name="check"/>
Success bold button
</button>
<button class="info">
<Icon name="info"/>
Info button
</button>
<button class="info bold">
<Icon name="info"/>
Info bold button
</button>
<button class="warning">
<Icon name="alert-triangle"/>
Warning button
</button>
<button class="warning bold">
<Icon name="alert-triangle"/>
Warning bold button
</button>
<button class="error">
<Icon name="x-circle"/>
Error button
</button>
<button class="error bold">
<Icon name="x-circle"/>
Error bold button
</button>
</p>

View File

@ -0,0 +1,35 @@
<script>
import {locals} from "../../../ts/stores";
import Message from "../../components/Message.svelte";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let newName = '';
</script>
{#if hasRoute('change-name')}
<section class="panel">
<h2><Icon name="key"/> Change name</h2>
{#if $locals.can_change_name}
<Form action={route('change-name')}
submitIcon="save" submitText="Change my name {newName.length > 0 ? 'to ' + newName : ''}"
confirm="Are you sure you want to change your name to {newName}?">
<Field type="text" name="name" icon="user" placeholder="New name" required bind:value={newName}/>
<Field type="checkbox" name="terms"
placeholder="I understand that I can only change my name once every {$locals.name_change_wait_period}"
required/>
<Field type="checkbox" name="terms2"
placeholder="I understand that my old name {$locals.user.name} will become available for anyone to take"
required/>
</Form>
{:else}
<Message type="info" content="You will be able to change your name in {$locals.can_change_name_in}" sticky discreet/>
{/if}
</section>
{/if}

View File

@ -0,0 +1,34 @@
<script>
import {locals} from "../../../ts/stores";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let removePasswordMode = false;
</script>
{#if hasRoute('remove-password', 'change-password')}
<section class="panel">
<h2><Icon name="key"/> {$locals.has_password ? 'Change' : 'Set'} password</h2>
{#if removePasswordMode}
<Form action={route('remove-password')}
submitIcon="trash" submitText="Remove password" submitClass="danger"
confirm="Are you sure you want to remove your password?">
<button type="button" on:click={() => removePasswordMode = false}>Go back</button>
</Form>
{:else}
<Form action={route('change-password')}
submitIcon="save" submitText="Set password">
{#if $locals.has_password}
<Field type="password" name="current_password" icon="key" placeholder="Current password"/>
<button type="button" on:click={() => removePasswordMode = true}>Forgot your password?</button>
{/if}
<Field type="password" name="new_password" icon="key" placeholder="New password" required/>
<Field type="password" name="new_password_confirmation" icon="key" placeholder="New password confirmation" required/>
</Form>
{/if}
</section>
{/if}

View File

@ -0,0 +1,109 @@
<script>
import {locals} from "../../../ts/stores";
import BaseTemplate from "../../templates/BaseTemplate.svelte";
import Message from "../../components/Message.svelte";
import NamePanel from "./NamePanel.svelte";
import PasswordPanel from "./PasswordPanel.svelte";
import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
const mainEmail = $locals.main_email?.email;
const personalInfoFields = $locals.user_personal_info_fields || [];
const emails = $locals.emails || [];
</script>
<BaseTemplate title="Account" description="Manage your account settings and data.">
<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}
{#each personalInfoFields as field}
<p>{field.name}: {field.value}</p>
{/each}
{#if mainEmail}
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
{/if}
</section>
{#if $locals.has_name_component}
<NamePanel/>
{/if}
{#if $locals.has_password_component}
<PasswordPanel/>
{/if}
<section class="panel">
<h2 id="emails">
<Icon name="shield"/>
Email addresses
</h2>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each emails as email}
{#if email.id === $locals.user.main_email_id}
<tr>
<td>Main</td>
<td>{email.email}</td>
<td></td>
</tr>
{/if}
{/each}
{#each emails as email}
{#if email.id !== $locals.user.main_email_id}
<tr>
<td>Secondary</td>
<td>{email.email}</td>
<td class="actions">
<Form action={route('set-main-email')} button
submitIcon="refresh-ccw" submitText="Set as main address"
submitClass="warning"
confirm="Are you sure you want to set {email.email} as your main address?">
<Field type="hidden" name="id" value={email.id}/>
</Form>
<Form action={route('remove-email')} button
submitIcon="trash" submitText="Remove" submitClass="danger"
confirm="Are you sure you want to delete {email.email}?">
<Field type="hidden" name="id" value={email.id}/>
</Form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if hasRoute('add-email')}
<Form action={route('add-email')} class="sub-panel"
submitIcon="plus" submitText="Add email address">
<h3>Add an email address:</h3>
<Field type="email" name="email" icon="at-sign" placeholder="Choose a safe email address"
hint="An email address we can use to identify you in case you lose access to your account"
required/>
</Form>
{/if}
</section>
</BaseTemplate>

View File

@ -0,0 +1,99 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte";
import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
let queryStr = '';
let previousUrl: string = $locals.previousUrl as string | undefined;
if ($locals.query?.redirect_uri) {
queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString();
} else if (previousUrl) {
queryStr = '?' + new URLSearchParams({redirect_uri: previousUrl}).toString();
}
</script>
<BaseTemplate title="Authentication / Registration"
description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration">
{#if hasRoute('login')}
<section class="panel">
<h2>
<Icon name="log-in"/>
Log in
</h2>
<Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
<Field type="text" name="identifier" value={$locals.query?.identifier} icon="at-sign"
hint={loginUsingMagicLink ? 'You will receive a magic link in your mailbox. Click on the link from any device to authenticate here.' : ''}
placeholder="Your email address or username" required/>
{#if $locals.hasPassword}
{#if loginUsingMagicLink}
<button on:click={() => loginUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{:else}
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
<button on:click={() => loginUsingMagicLink=true} type="button">
<Icon name="mail"/>
Use magic link
</button>
{/if}
{/if}
<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>
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
{#if $locals.hasUsername}
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/>
{/if}
{#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/>
{#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>
{/if}
<Field type="checkbox" name="terms" icon="file-text" required>
I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.
</Field>
</Form>
</section>
{/if}
</BaseTemplate>

View File

@ -0,0 +1,81 @@
<script lang="ts">
import {locals} from "../../ts/stores";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Pagination from "../components/Pagination.svelte";
import Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte";
import {hasRoute, route} from "../../../common/Routing";
const accounts = $locals.accounts || [];
</script>
<style>
td.empty {
text-align: center;
}
</style>
<BaseTemplate title="{$locals.app.name} - Review accounts" h1={false}>
{#if hasRoute('backend')}
<Breadcrumb currentPageTitle="Accounts pending review" pages={[
{link: route('backend'), title:'Backend'},
]}/>
{/if}
<h1>Accounts pending review</h1>
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
<div class="panel data-table-container">
<table class="data-table">
<thead>
<tr>
<th class="shrink-col">#</th>
{#if $locals.has_user_name_component}
<th>Name</th>
{/if}
<th>Main email</th>
<th>Registered at</th>
<th class="shrink-col">Action</th>
</tr>
</thead>
<tbody>
{#each accounts as user}
<tr>
<td>{user.id}</td>
{#if $locals.has_user_name_component}
<td>{user.name}</td>
{/if}
<td>{user.mainEmailStr || 'No email'}</td>
<td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td>
<td>
<div class="max-content">
{#if hasRoute('approve-account')}
<Form action={route('approve-account')}
submitIcon="check" submitText="Approve" submitClass="success">
<Field type="hidden" name="user_id" value={user.id}/>
</Form>
{/if}
{#if hasRoute('reject-account')}
<Form action={route('reject-account')}
submitIcon="trash" submitText="Reject" submitClass="danger"
confirm="This will irrevocably delete the {user.mainEmailStr || user.name || user.id} account.">
<Field type="hidden" name="user_id" value={user.id}/>
</Form>
{/if}
</div>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="empty">No account to review.</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
</BaseTemplate>

View File

@ -0,0 +1,31 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import BaseTemplate from "../templates/BaseTemplate.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte";
import Icon from "../utils/Icon.svelte";
const menu = $locals.menu || [];
</script>
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
<Breadcrumb currentPageTitle="Backend"/>
<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>
</BaseTemplate>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
export let currentPageTitle: string;
export let pages: { link: string, title: string }[] = [];
</script>
<style lang="scss">
ol {
display: flex;
flex-direction: row;
margin: 8px 0;
padding: 0 8px;
list-style: none;
overflow-x: auto;
border-radius: 5px;
li {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
a, span {
display: block;
padding: 8px;
}
}
}
</style>
<nav aria-label="breadcrumb">
<ol class="breadcrumb panel">
{#each pages as page}
<li><a href={page.link}>{page.title}</a> <Icon name="chevron-right"/></li>
{/each}
<li class="active" aria-current="page"><span>{currentPageTitle}</span></li>
</ol>
</nav>

View File

@ -0,0 +1,123 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
import { fade } from "svelte/transition";
export let title: string | undefined = undefined;
export let content: string;
export let buttonMode: boolean = false;
let contentNode: HTMLElement;
let copiedOverlay: HTMLElement;
function selectAll() {
const selection = window.getSelection();
if (contentNode && selection) {
selection.selectAllChildren(contentNode);
}
}
function copy() {
const selection = window.getSelection();
if (contentNode && selection) {
selectAll();
navigator.clipboard.writeText(contentNode.innerText);
showOverlay();
}
}
let showCopiedOverlay = false;
function showOverlay() {
showCopiedOverlay = true;
}
function releaseOverlay() {
showCopiedOverlay = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
.copyable-text {
position: relative;
display: flex;
flex-direction: row;
margin: 8px;
padding: 0;
border-radius: 5px;
overflow: hidden;
.title {
padding: 8px;
}
.content {
width: 0;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
padding: 8px;
}
.copy-button {
margin: 0;
padding: 0;
border-radius: 0;
:global(.icon) {
--icon-size: 20px;
margin: 8px;
}
}
}
.button-mode-button {
position: relative;
}
.copied-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 8px;
text-align: center;
background-color: var(--success);
}
.content.hidden {
pointer-events: none;
overflow: hidden;
width: 0;
height: 0;
margin: 0;
padding: 0;
position: absolute;
}
</style>
{#if buttonMode}
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div>
<button class="bold button-mode-button" on:click={copy} title="{content}">
<Icon name="copy"/>
{#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

@ -0,0 +1,29 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import Message from "./Message.svelte";
export let flashed = $locals.flash;
const displayedCategories = [
'success',
'info',
'warning',
'error',
'error-alert',
];
</script>
<style lang="scss">
.messages :global(.message:not(:last-child)) {
margin-bottom: 8px;
}
</style>
<div class="messages">
{#if flashed}
{#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}
{/each}
{/if}
</div>

View File

@ -0,0 +1,69 @@
<script>
import {fade} from 'svelte/transition';
export let show = true;
export let size = '72px';
</script>
<style lang="scss">
.loader {
position: fixed;
z-index: 10000;
width: 100%;
height: 100%;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--background-color);
.parts {
--size: 72px;
position: relative;
width: var(--size);
height: var(--size);
.bg-circle {
opacity: 0.5;
}
.fg-arc {
border-top: 8px solid transparent;
border-left: 8px solid transparent;
border-bottom: 8px solid transparent;
animation: infinite linear 2s spin;
}
> * {
position: absolute;
width: 100%;
height: 100%;
border: 8px solid #fff;
border-radius: 100%;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
{#if show}
<div class="loader" style="--size: {size};" transition:fade={{duration: 50}}>
<div class="parts">
<div class="bg-circle"></div>
<div class="fg-arc"></div>
</div>
</div>
{/if}

View File

@ -0,0 +1,123 @@
<script>
import Icon from "../utils/Icon.svelte";
export let type;
export let content;
export let raw = false;
export let discreet = false;
export let sticky = false;
let icon = undefined;
switch (type) {
case 'success':
icon = 'check';
break;
case 'info':
icon = 'info';
break;
case 'warning':
icon = 'alert-triangle';
break;
case 'error':
icon = 'x-circle';
break;
case 'error-alert':
icon = 'alert-circle';
break;
}
let message;
function hide() {
message.remove();
}
</script>
<style lang="scss">
@import "../../scss/vars";
.message {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 16px;
border-radius: 5px;
:global(.icon) {
--icon-size: 24px;
margin-right: 8px;
}
&:not(&-discreet) {
background-color: rgba(255, 255, 255, 0.33);
&[data-type=info], &[data-type=question] {
color: var(--on-info);
background-color: var(--info);
}
&[data-type=success] {
color: var(--on-success);
background-color: var(--success);
}
&[data-type=warning] {
color: var(--on-warning);
background-color: var(--warning);
}
&[data-type=error], &[data-type=error-alert] {
color: var(--on-error);
background-color: var(--error);
}
}
&-discreet {
color: var(--on-surface);
.icon {
--icon-size: 20px;
}
}
.content {
flex-grow: 1;
}
button {
background-color: transparent;
color: inherit;
margin: 0;
padding: 0;
border: 0;
:global(.icon) {
margin: 0;
}
}
}
</style>
<div class="message" class:message-discreet={discreet} data-type="{type}" bind:this={message}>
{#if icon}
<Icon name={icon}/>
{/if}
<span class="content">
{#if raw}
{@html content}
{:else}
{content}
{/if}
</span>
{#if !sticky}
<button type="button" on:click={hide}>
<Icon name="x"/>
</button>
{/if}
</div>

View File

@ -0,0 +1,126 @@
<script>
import {onMount} from "svelte";
import Icon from "../utils/Icon.svelte";
let open = false;
let locked = false;
function stopPropagation(e) {
e.stopPropagation();
}
function openMenu() {
if (locked) return;
open = true;
}
function closeMenu() {
if (locked) return;
open = false;
}
function lock() {
locked = true;
window.requestAnimationFrame(() => {
locked = false;
});
}
let nav;
onMount(() => {
nav.querySelectorAll('ul li > a, ul li > form > button')
.forEach(el => {
el.addEventListener('focus', () => {
openMenu();
});
el.addEventListener('blur', () => {
closeMenu();
});
});
});
</script>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
nav {
top: 0;
left: 0;
height: 100%;
padding: 16px;
font-size: 16px;
@include medium-le {
z-index: 1;
position: fixed;
padding: 16px;
@include surface(3);
transition: transform ease-out 150ms;
&:not(.open) {
transform: translateX(-100%);
}
}
ul {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
list-style: none;
@include large-ge {
flex-direction: row;
align-items: center;
}
}
}
button {
width: #{$headerHeight - 16px};
height: #{$headerHeight - 16px};
margin: 8px;
padding: 8px;
border: 0;
justify-content: center;
align-items: center;
background: var(--surface);
color: var(--on-surface);
border-radius: $headerHeight;
:global(.icon) {
--icon-size: 28px;
margin: 0;
}
@include large-ge {
display: none;
}
}
</style>
<svelte:window on:click={closeMenu}/>
<button on:click={openMenu} on:click={stopPropagation}
on:focus={openMenu} on:blur={closeMenu}
tabindex="0" aria-label="Toggle menu">
<Icon name="menu"/>
</button>
<nav class:open on:click={openMenu} on:click={stopPropagation} on:mousedown={lock} bind:this={nav}
aria-hidden={open ? 'false' : 'true'}>
<ul>
<slot/>
</ul>
</nav>

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

@ -0,0 +1,94 @@
<script>
import Icon from "../utils/Icon.svelte";
import Form from "../utils/Form.svelte";
export let href;
export let icon;
export let text;
export let action = false;
export let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
li {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
line-height: 1;
@mixin aHover {
background-color: rgba(0, 0, 0, 0.07);
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
}
}
a {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: auto;
padding: 8px;
white-space: nowrap;
border-radius: 3px;
text-transform: uppercase;
@include medium-le {
&:hover {
@include aHover;
}
}
}
@include large-ge {
&:hover > a {
@include aHover;
}
}
:global(form) {
width: 100%;
:global(button) {
display: flex;
width: 100%;
margin: 0;
padding: 8px;
}
}
:global(.icon) {
--icon-size: 16px;
margin-right: 8px;
}
}
</style>
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
{#if action}
<Form action={href} submitIcon={icon} submitText={text}/>
{:else}
<a href={href}>
<Icon name={icon}/>
<span class="tip">{text}</span></a>
{/if}
<slot/>
</li>

View File

@ -0,0 +1,88 @@
<script lang="ts">
import {route} from "../../../common/Routing.js";
import {Pagination} from "../../../common/Pagination.js";
import Icon from "../utils/Icon.svelte";
export let pagination: string;
export let routeName: string;
export let contextSize: number;
if (typeof contextSize !== 'number') contextSize = parseInt(contextSize);
$: paginationObj = pagination ? Pagination.deserialize(pagination) : null;
</script>
<style lang="scss">
@import "../../scss/helpers";
.pagination {
ul {
display: flex;
flex-direction: row;
width: fit-content;
max-width: 100%;
margin: 8px auto;
padding: 0;
list-style: none;
overflow-x: auto;
border-radius: 5px;
li {
white-space: nowrap;
a, span {
display: block;
padding: 8px 12px;
&:not(span):hover {
background: #0002;
@media (prefers-color-scheme: dark) {
background: #fff2;
}
}
}
}
}
}
</style>
{#if paginationObj && (paginationObj.hasPrevious() || paginationObj.hasNext())}
<nav class="pagination">
<ul class="panel">
{#if paginationObj.hasPrevious()}
<li><a href={route(routeName, {page: paginationObj.page - 1})}>
<Icon name="chevron-left"/>
Previous
</a></li>
{#each paginationObj.previousPages(contextSize) as i}
{#if i === -1}
<li class="ellipsis"><span>...</span></li>
{:else}
<li><a href={route(routeName, {page: i})}>{i}</a></li>
{/if}
{/each}
{/if}
<li class="active"><span>{paginationObj.page}</span></li>
{#if paginationObj.hasNext()}
{#each paginationObj.nextPages(contextSize) as i}
{#if i === -1}
<li class="ellipsis"><span>...</span></li>
{:else}
<li><a href={route(routeName, {page: i})}>{i}</a></li>
{/if}
{/each}
<li><a href={route(routeName, {page: paginationObj.page + 1})}>
Next
<Icon name="chevron-right"/>
</a></li>
{/if}
</ul>
</nav>
{/if}

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={400} message="Bad request." />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={401} message="Unauthorized." />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={403} message="Forbidden" />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={404} message="Page not found." />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={429} message="Too many requests." />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={500} message="Internal server error." />

View File

@ -0,0 +1,5 @@
<script>
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
</script>
<ErrorTemplate code={503} message="Service unavailable." />

View File

@ -0,0 +1,33 @@
<script lang="ts">
import {locals} from "../ts/stores";
import {route, hasRoute, hasAnyRoute} from "../../common/Routing";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import {onMount} from "svelte";
let randomTitleSWord = 'Svelte';
const possibleSWords = ['Svelte', 'Simple', 'Scalable', 'Super', 'Structure', 'Satisfying'];
onMount(() => {
randomTitleSWord = possibleSWords[Math.floor(Math.random() * possibleSWords.length)];
});
</script>
<BaseTemplate title="{$locals.app.name}" h1={false}>
<div class="panel">
<h1>swaf - {randomTitleSWord} Web Application Framework</h1>
<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>
{/if}
</div>
</BaseTemplate>

View File

@ -0,0 +1,19 @@
<script>
import {locals} from "../ts/stores";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import Message from "./components/Message.svelte";
const actionType = $locals.magicLink?.action_type;
const h1 = 'Magic Link' + (actionType ? (' - ' + actionType) : '');
</script>
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
<div class="panel">
{#if $locals.err}
<Message type="error" content={$locals.err}/>
{:else}
<Message type="success" content="Success!"/>
<p>You can now close this page.</p>
{/if}
</div>
</BaseTemplate>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import {locals} from "../ts/stores.js";
import BaseTemplate from "./templates/BaseTemplate.svelte";
import Message from "./components/Message.svelte";
import WebsocketClient from "../ts/WebsocketClient.js";
import {Time} from "../../common/Time.js";
import {onMount} from "svelte";
const validUntil = parseFloat($locals.validUntil as string);
function isValid() {
return new Date().getTime() < validUntil;
}
let countdown;
let validUntilDate = new Date(validUntil);
$: countdown = $locals.isSsr ? '...' : Time.humanizeTimeTo(validUntilDate);
onMount(() => {
const interval = setInterval(() => {
validUntilDate = new Date(validUntil);
}, 1000);
if (isValid()) {
const webSocket = new WebsocketClient($locals.websocketUrl as string, (websocket, e) => {
if (e.data === 'refresh') {
window.location.reload();
}
});
webSocket.run();
}
return () => {
clearInterval(interval);
};
});
</script>
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<div class="panel">
<Message type="success" sticky
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
<Message type="info" discreet sticky raw
content={`This link will be valid for ${countdown} and can only be used once.`}/>
<p class="center">Waiting for you to open the link...</p>
</div>
</BaseTemplate>

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -38,4 +38,4 @@
If you believe that this is an error, please contact us via email.
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -24,4 +24,4 @@
Someone wants to add {{ mail_to }} to their account.
To add this email address, please follow this link: {{ link|safe }}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -63,4 +63,4 @@
Location: {{ geo }}
To authorize this log in, please follow this link: {{ link|safe }}
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -23,4 +23,4 @@
Username: {{ username }}
To review this account, please follow this link: {{ link|safe }}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import {onMount} from "svelte";
import Icon from "../utils/Icon.svelte";
let iconTemplate: HTMLTemplateElement;
function addExternalLinkIcons(): void {
console.log('Add icons to external links...');
const iconElement = iconTemplate.childNodes.item(0);
document.querySelectorAll('a[target="_blank"]').forEach(el => {
if (!el.classList.contains('no-icon')) {
el.classList.add('no-icon');
el.appendChild(iconElement.cloneNode(true));
}
});
}
onMount(() => {
addExternalLinkIcons();
new MutationObserver(() => {
addExternalLinkIcons();
}).observe(document.body, {
childList: true,
subtree: true,
});
});
</script>
<style>
div {
display: none;
}
</style>
<div bind:this={iconTemplate}>
<Icon name="external-link"/>
</div>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import FlashMessages from "../components/FlashMessages.svelte";
import BaseFooter from "./base/BaseFooter.svelte";
import BaseHeader from "./base/BaseHeader.svelte";
import CommonScripts from "./CommonScripts.svelte";
import {locals} from '../../ts/stores.js';
export let title: string;
export let h1: string = title;
export let description: string;
export let previewImageUrl: string | undefined = undefined;
export let refresh_after: number | undefined = undefined;
export let noHeader: boolean = false;
export let noH1: boolean = false;
export let noFooter: boolean = false;
export let noLogoLabel = false;
export let noLoginLink = false;
</script>
<CommonScripts/>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
main {
@include container;
flex-grow: 1;
}
.flash-messages {
@include container;
}
</style>
<svelte:head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title || 'Undefined title'}</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content={title}>
<meta property="og:url" content="{$locals.app.public_url + $locals.url}">
<meta property="twitter:title" content={title}>
<meta property="twitter:url" content={$locals.app.public_url + $locals.url}>
{#if description}
<meta name="description" content={description}>
<meta property="og:description" content={description}>
<meta property="twitter:description" content={description}>
{/if}
{#if previewImageUrl}
<meta property="og:image" content={previewImageUrl}>
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:image" content={previewImageUrl}>
{/if}
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">
<link rel="shortcut icon" type="image/png" href="/img/logox128.png">
<link rel="shortcut icon" type="image/svg" href="/img/logo.svg">
{#if refresh_after}
<meta http-equiv="refresh" content={refresh_after}>
{/if}
<link rel="stylesheet" href="/css/layout.css">
</svelte:head>
{#if !noHeader}
<BaseHeader {noLogoLabel} {noLoginLink}/>
{/if}
<div class="flash-messages">
<FlashMessages/>
</div>
<main>
{#if h1 && !noH1}
<h1>{h1}</h1>
{/if}
{#if $$slots.subtitle}
<p>
<slot name="subtitle"/>
</p>
{/if}
<slot/>
</main>
{#if !noFooter}
<BaseFooter/>
{/if}

View File

@ -0,0 +1,5 @@
<script>
import ExternalLinkIcons from "../scripts/ExternalLinkIcons.svelte";
</script>
<ExternalLinkIcons/>

View File

@ -0,0 +1,145 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import FlashMessages from "../components/FlashMessages.svelte";
import Icon from "../utils/Icon.svelte";
import CommonScripts from "./CommonScripts.svelte";
const previousUrl = $locals.previousUrl;
export let code;
code = $locals.error_code || code;
export let message;
message = $locals.error_message || message;
export let instructions;
instructions = $locals.error_instructions || instructions;
</script>
<CommonScripts/>
<svelte:head>
<title>{code + ' - ' + message}</title>
<link rel="stylesheet" href="/css/layout.css">
</svelte:head>
<div class="logo"><a href="/">{$locals.app.name}</a></div>
<main>
<FlashMessages/>
<div class="error-code">{code}</div>
<div class="error-message">{message}</div>
{#if instructions}
<div class="error-instructions">{@html instructions}</div>
{/if}
<nav>
{#if previousUrl && previousUrl !== '/' && previousUrl !== $locals.url}
<a href={previousUrl} class="button bold"><Icon name="arrow-left"/> Go back</a>
{/if}
<a href="/" class="button"><Icon name="home"/> Go to homepage</a>
</nav>
</main>
<div class="contact">
<p>Error ID: {$locals.error_id || 'Request has no indentifier.'}</p>
<p>
If you think this isn't right, please contact us with the above error ID at
<a href="mailto:{$locals.app.contact_email}">{$locals.app.contact_email}</a>.
</p>
</div>
<style lang="scss">
header, footer {
margin: 0;
padding: 0;
height: 0;
}
main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.messages {
margin-bottom: 32px;
}
.error-code {
font-size: 36px;
}
.error-message {
font-size: 32px;
}
.error-instructions {
margin-top: 32px;
font-size: 20px;
}
nav {
margin-top: 32px;
}
&::before {
content: "Oops";
position: absolute;
z-index: -1;
font-size: #{'min(50vh, 40vw)'};
opacity: 0.025;
}
}
.contact {
text-align: center;
padding: 8px;
}
.logo {
position: absolute;
top: 0;
left: 0;
width: 100%;
margin-top: 24px;
text-align: center;
a {
position: relative;
padding: 16px;
color: var(--on-background);
&:hover {
color: #fff;
&::before {
opacity: 0.2;
}
}
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-image: url(../../img/logo.svg);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
opacity: 0.075;
filter: contrast(0);
}
}
}
</style>

View File

@ -0,0 +1,12 @@
<script>
import {locals} from "../../../ts/stores.js";
</script>
<style>
footer {
padding: 8px;
text-align: center;
}
</style>
<footer>{$locals.app.name} v{$locals.app.version} - all rights reserved.</footer>

View File

@ -0,0 +1,46 @@
<script>
import BaseHeaderLogo from "./BaseHeaderLogo.svelte";
import NavMenu from "../../components/NavMenu.svelte";
import BaseNavMenuLinks from "./BaseNavMenuLinks.svelte";
import BaseNavMenuAuth from "./BaseNavMenuAuth.svelte";
export let noLoginLink = false;
export let noLogoLabel = false;
</script>
<style lang="scss">
@import "../../../scss/vars";
@import "../../../scss/helpers";
header {
@if $headerContainer {
@include container;
}
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0;
height: $headerHeight;
@include medium-le {
z-index: 1;
position: sticky;
top: 0;
flex-direction: row-reverse;
@include surface(3);
}
}
</style>
<header>
<BaseHeaderLogo noLabel={noLogoLabel}/>
<NavMenu>
<BaseNavMenuLinks/>
<BaseNavMenuAuth {noLoginLink}/>
</NavMenu>
</header>

View File

@ -0,0 +1,33 @@
<script>
import {locals} from "../../../ts/stores.js";
export let noLabel = false;
</script>
<style lang="scss">
@import "../../../scss/vars";
.logo {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
font-size: 24px;
img {
flex-shrink: 0;
width: initial;
height: calc(#{$headerHeight} - 16px);
margin-right: 8px;
padding: 8px;
}
}
</style>
<a href="/" class="logo">
<img src="/img/logo.svg" alt="{$locals.app.name} logo">
{#if !noLabel}
<span class="label">{$locals.app.name}</span>
{/if}
</a>

View File

@ -0,0 +1,27 @@
<script>
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')}
{#if $locals.user}
{#if $locals.user.is_admin}
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'} bind:hovered={accountItemHovered}>
<NavMenuDropdown bind:open={accountItemHovered}>
<BaseNavMenuAuthAccountDropdownAdditionalLinks/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
</NavMenuDropdown>
</NavMenuItem>
{:else if !noLoginLink}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if}
{/if}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
%head%
<style>%css%</style>
<script type="module" defer>
import View from '/js/views/%canonicalViewName%.js';
import * as Routing from '/js/Routing.js';
const setRoutes = Routing.R.setRoutes;
const setPublicUrl = Routing.R.setPublicUrl;
import * as stores from '/js/stores.js';
const localStore = stores.l;
localStore.set(%locals%);
setRoutes(%routes%);
setPublicUrl(`%publicUrl%`);
new View({
hydrate: true,
target: document.body,
});
</script>
</head>
<body>
%html%
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"outDir": "public/js",
"rootDir": "../../../intermediates/assets",
},
"include": [
"src/assets/ts/**/*"
],
"references": [
{
"path": "../../common"
}
]
}

View File

@ -0,0 +1,6 @@
<script lang="ts">
import {locals} from "../../ts/stores.js";
import Field from "./Field.svelte";
</script>
<Field type="hidden" name="csrf" value={$locals.csrfToken}/>

View File

@ -0,0 +1,484 @@
<script lang="ts">
import {locals} from '../../ts/stores.js';
import {FileSize} from "../../../common/FileSize.js";
import Message from "../components/Message.svelte";
import Icon from "./Icon.svelte";
import {getContext} from "svelte";
import {dateToDatetimeLocal, dateToIsoString} from "../../ts/datetime-local.js";
export let type: string;
export let name: string;
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.flash.validation?.[0]?.[name] as { message: string, value?: string } | undefined;
const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.flash.previousFormData?.[0] as Record<string, FieldValue> | undefined || {};
let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
value = type !== 'hidden' && previousFieldData || value || initialValue || validation?.value || '';
$: initialDatetimeLocalValue = type === 'datetime-local' && typeof value === 'string' ? dateToDatetimeLocal(new Date(value)) : undefined;
function durationValue(f: string): number {
if (previousFormData[name]) {
return value[f];
}
switch (f) {
case 's':
return value % 60;
case 'm':
return (value - value % 60) / 60 % 60;
case 'h':
return (value - value % 3600) / 3600;
default:
return 0;
}
}
function focusInput(e) {
if (input) {
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();
}
} else {
this.querySelector('input')?.focus();
}
}
function handleInput() {
switch (this.type) {
case 'number':
case 'range':
value = +this.value;
break;
case 'file':
handleFileInput();
break;
case 'datetime-local':
value = dateToIsoString(new Date(this.value));
break;
case 'checkbox':
value = !!this.checked;
break;
default:
value = this.value;
break;
}
}
let input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() {
input.click();
}
export let fileList: FileList | undefined = undefined;
function handleFileInput() {
fileList = input.files;
}
let focused = false;
</script>
<style lang="scss">
@import "../../scss/helpers";
.form-field:not(.hidden) {
display: flex;
flex-direction: column;
margin: 16px auto;
.control {
position: relative;
z-index: 0;
display: flex;
align-items: start;
flex-direction: row;
color: var(--on-input);
background-color: var(--input);
border-radius: 5px;
> :global(.icon) {
--icon-size: 24px;
margin: 18px;
opacity: 0.75;
}
}
label {
position: absolute;
z-index: 1;
left: 8px;
top: 22px;
user-select: none;
font-size: 16px;
transition-property: top, font-size;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: text;
}
.has-icon label {
left: 68px;
}
&.disabled {
opacity: 0.5;
&, * {
cursor: not-allowed;
}
}
input, select, textarea {
z-index: 1;
border: 0;
color: inherit;
background: transparent;
font-size: 16px;
border-radius: 5px;
outline-offset: 0;
}
&:not(.empty),
select ~,
[type="file"] ~,
[type="color"] ~,
[type="number"] ~,
.focused ~,
:focus ~,
fieldset {
.sections label, legend, .time-input label {
top: 8px;
font-size: 14px;
}
}
input,
select,
select,
textarea,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after {
display: block;
padding: 32px 8px 8px;
width: 100%;
height: 60px;
}
select {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&::-ms-expand {
display: none;
}
& + :global(.icon) {
position: absolute;
pointer-events: none;
right: 0;
top: 0;
transition: transform 150ms ease-out;
}
// TODO: Temporary
&:focus + :global(.icon) {
transform: rotateX(180deg);
}
}
.textarea-growing-wrapper {
display: grid;
flex-grow: 1;
height: revert;
padding: 0;
&:after {
content: attr(data-value) " ";
color: red;
box-sizing: border-box;
font: inherit;
visibility: hidden;
}
textarea {
height: auto;
resize: none;
font-family: inherit;
overflow: hidden;
}
&:after, textarea {
grid-area: 1 / 1 / 2 / 2;
margin-left: revert;
min-height: 100px;
height: revert;
white-space: pre-wrap;
word-wrap: anywhere;
}
}
.has-icon > {
input,
select,
select,
fieldset,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after,
.textarea-growing-wrapper > textarea {
margin-left: -60px;
padding-left: 68px;
}
.textarea-growing-wrapper > textarea {
margin-left: -68px;
width: calc(100% + 68px);
}
}
input[type=color] {
height: calc(32px + 8px + 32px);
}
&.checkbox, &.color, &.file {
input, label {
cursor: pointer;
}
}
&.checkbox {
.control {
display: flex;
flex-direction: row;
align-items: stretch;
flex-grow: 1;
}
input {
width: 20px;
height: 20px;
margin: auto 8px;
text-align: left;
& ~ .sections {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
label {
position: static;
font-size: 16px;
}
}
}
}
&.file {
input {
@include fake-hide;
}
label {
position: static;
display: block;
padding: 8px;
font-size: revert !important;
}
}
.files {
display: flex;
flex-direction: column;
align-items: start;
.file {
margin: 8px;
padding: 8px;
color: var(--on-primary);
background: var(--primary);
border-radius: 5px;
> div {
display: inline-block;
padding: 8px;
}
.name {
font-size: 20px;
:global(.icon) {
--icon-size: 24px;
margin-right: 8px;
}
}
}
}
fieldset {
display: flex;
flex-direction: row;
align-items: start;
width: 100%;
margin: 0;
padding-top: 8px;
border: 0;
legend {
padding: 8px;
}
.time-input {
position: relative;
flex: 1;
margin: 0;
label {
left: 8px;
}
}
+ {
.error, .hint {
margin-top: -16px;
margin-bottom: 16px;
}
}
}
}
.form-field, fieldset + {
.error, .hint {
padding: 2px 2px 2px 4px;
text-align: left;
font-size: 14px;
color: var(--color);
}
}
</style>
{#if type === 'hidden'}
{#if validation}
<Message type="error" content={validation.message}/>
{/if}
<input type="hidden" name={name} value={value}>
{:else}
<div class="form-field"
class:checkbox={type === 'checkbox'}
class:color={type === 'color'}
class:file={type === 'file'}
class:empty={value === ''}
class:disabled={Object.keys($$restProps).indexOf('disabled') >= 0}>
<div class="control" class:has-icon={icon} on:click={focusInput}>
{#if icon}
<Icon name={icon}/>
{/if}
{#if type === 'duration'}
<fieldset>
<legend>{placeholder}</legend>
{#each Object.keys(extraData) as f}
<div class="time-input">
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
value={durationValue(f)}
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
{...$$restProps} on:click={e => e.stopPropagation()}>
<label for="{fieldId}-{f}" on:click={e => e.stopPropagation()}>{extraData[f] || f}</label>
</div>
{/each}
</fieldset>
{:else if type === 'select'}
<select name={name} id={fieldId} {...$$restProps} bind:this={input} bind:value={value} on:input={handleInput}>
{#each extraData as option}
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
>{option.display || option}</option>
{/each}
</select>
<Icon name="chevron-down"/>
{:else if type === 'textarea'}
<div class="textarea-growing-wrapper" class:focused={focused} data-value={value}>
<textarea {name} id={fieldId} {value} {...$$restProps} bind:this={input}
on:input={handleInput}
on:focusin={() => focused = true}
on:focusout={() => focused = false}></textarea>
</div>
{:else if type === 'checkbox'}
<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}>
{:else}
<input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput}
tabindex={type === 'file' ? '-1' : undefined}>
{/if}
<div class="sections">
{#if type !== 'duration'}
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
<slot/>
</label>
{/if}
{#if type === 'file'}
{#if fileList}
<div class="files">
{#each fileList as file}
<div class="file">
<div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name}
</div>
<div class="size" title="{file.size} bytes">
{FileSize.humanizeFileSize(file.size, true)}
</div>
</div>
{/each}
</div>
{/if}
<button type="button" on:click={chooseFile}>Browse...</button>
{/if}
</div>
</div>
{#if validation}
<div class="error">
<Icon name="alert-circle"/> {validation.message}</div>
{/if}
{#if hint}
<div class="hint">
<Icon name="info"/> {hint}</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1,61 @@
<script context="module">
let nextAvailableFormId = 0;
</script>
<script lang="ts">
import CsrfTokenField from "./CsrfTokenField.svelte";
import Icon from "./Icon.svelte";
import {setContext} from "svelte";
export let action: string;
export let button: boolean = false;
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;
export let withFiles: boolean = false;
const formId = nextAvailableFormId++;
setContext('formId', formId);
export let onSubmit = function(e) {
if (submitDisabled || confirm && !window.confirm(confirm)) {
e.preventDefault();
}
};
</script>
<style lang="scss">
.form-controls {
display: flex;
justify-content: space-between;
> :last-child:not(* + :last-child) {
margin-left: auto;
margin-right: auto;
}
}
</style>
<form {action} method="POST" id="{formId}-form" on:submit={onSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
<CsrfTokenField/>
<slot/>
<div class="form-controls">
{#if resetButton}
<button type="reset"><Icon name="trash"/>Reset</button>
{/if}
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
{#if submitIcon}
<Icon name={submitIcon}/>
{/if}
{#if button}
<span class="tip">{submitText}</span>
{:else}
{submitText}
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,47 @@
<script lang="ts">
import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
import {afterUpdate, onMount} from "svelte";
export let name: string;
onMount(() => {
replaceIcons(true);
});
afterUpdate(() => {
replaceIcons(false);
});
</script>
<style lang="scss">
:global(.icon) {
display: inline-flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: var(--icon-size);
height: var(--icon-size);
--icon-size: 16px;
font-size: var(--icon-size);
stroke: currentColor;
stroke-width: 2;
stroke-linecap: square;
stroke-linejoin: miter;
fill: none;
vertical-align: middle;
h1 > &, h2 > &, h3 > & {
--icon-size: 24px;
}
}
</style>
{#if name}
{#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

@ -1,19 +1,21 @@
import Controller from "../Controller";
import {RequireAuthMiddleware} from "./AuthComponent";
import {Request, Response} from "express";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "../HttpError";
import config from "config";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator";
import UserPasswordComponent from "./password/UserPasswordComponent";
import User from "./models/User";
import ModelFactory from "../db/ModelFactory";
import UserEmail from "./models/UserEmail";
import MagicLinkController from "./magic_link/MagicLinkController";
import {MailTemplate} from "../mail/Mail";
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
import UserNameComponent from "./models/UserNameComponent";
import Time from "../Time";
import {Request, Response} from "express";
import {route} from "../common/Routing.js";
import {Time} from "../common/Time.js";
import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator.js";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "../HttpError.js";
import MailTemplate from "../mail/MailTemplate.js";
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails.js";
import {RequireAuthMiddleware} from "./AuthComponent.js";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType.js";
import MagicLinkController from "./magic_link/MagicLinkController.js";
import User from "./models/User.js";
import UserEmail from "./models/UserEmail.js";
import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AccountController extends Controller {
@ -55,7 +57,8 @@ export default class AccountController extends Controller {
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
res.render('auth/account/account', {
res.formatViewData('auth/account/account', {
user_personal_info_fields: user.getPersonalInfoFields(),
main_email: await user.mainEmail.get(),
emails: await user.emails.get(),
display_email_warning: config.get('app.display_email_warning'),
@ -80,14 +83,14 @@ export default class AccountController extends Controller {
const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now();
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`);
res.redirect(Controller.route('account'));
res.redirect(route('account'));
return;
}
await user.save();
req.flash('success', `Your name was successfully changed to ${req.body.name}.`);
res.redirect(Controller.route('account'));
res.redirect(route('account'));
}
protected async postChangePassword(req: Request, res: Response): Promise<void> {
@ -100,7 +103,7 @@ export default class AccountController extends Controller {
const passwordComponent = user.as(UserPasswordComponent);
if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) {
req.flash('error', 'Invalid current password.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
return;
}
@ -108,7 +111,7 @@ export default class AccountController extends Controller {
await user.save();
req.flash('success', 'Password changed successfully.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
}
protected async postRemovePassword(req: Request, res: Response): Promise<void> {
@ -116,7 +119,7 @@ export default class AccountController extends Controller {
const mainEmail = await user.mainEmail.get();
if (!mainEmail || !mainEmail.email) {
req.flash('error', 'You can\'t remove your password without adding an email address first.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
return;
}
@ -125,14 +128,14 @@ export default class AccountController extends Controller {
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.REMOVE_PASSWORD,
Controller.route('account'),
route('account'),
mainEmail.email,
this.removePasswordMailTemplate,
{},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: route('account'),
}));
}
@ -155,7 +158,7 @@ export default class AccountController extends Controller {
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.ADD_EMAIL,
Controller.route('account'),
route('account'),
email,
this.addEmailMailTemplate,
{
@ -163,8 +166,8 @@ export default class AccountController extends Controller {
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: route('account'),
}));
}
@ -187,7 +190,7 @@ export default class AccountController extends Controller {
await user.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
}
protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
@ -207,6 +210,6 @@ export default class AccountController extends Controller {
await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
}
}

View File

@ -1,13 +1,14 @@
import ApplicationComponent from "../ApplicationComponent";
import {NextFunction, Request, Response} from "express";
import AuthGuard from "./AuthGuard";
import Controller from "../Controller";
import {ForbiddenHttpError} from "../HttpError";
import Middleware from "../Middleware";
import User from "./models/User";
import Application from "../Application";
import AuthMethod from "./AuthMethod";
import AuthProof from "./AuthProof";
import Application from "../Application.js";
import ApplicationComponent from "../ApplicationComponent.js";
import {route} from "../common/Routing.js";
import {ForbiddenHttpError} from "../HttpError.js";
import Middleware from "../Middleware.js";
import AuthGuard from "./AuthGuard.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
export default class AuthComponent extends ApplicationComponent {
private readonly authGuard: AuthGuard;
@ -17,7 +18,7 @@ export default class AuthComponent extends ApplicationComponent {
this.authGuard = new AuthGuard(app, ...authMethods);
}
public async init(): Promise<void> {
public async initRoutes(): Promise<void> {
this.use(AuthMiddleware);
}
@ -65,7 +66,7 @@ export class RequireRequestAuthMiddleware extends Middleware {
}
req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(Controller.route('auth', undefined, {
res.redirect(route('auth', undefined, {
redirect_uri: req.url,
}));
}
@ -101,7 +102,7 @@ export class RequireAuthMiddleware extends Middleware {
}
req.flash('error', `You must be logged in to access ${req.url}.`);
res.redirect(Controller.route('auth', undefined, {
res.redirect(route('auth', undefined, {
redirect_uri: req.url,
}));
}
@ -116,7 +117,7 @@ export class RequireGuestMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession());
if (proofs.length > 0) {
res.redirect(Controller.route('home'));
res.redirect(route('home'));
return;
}

View File

@ -1,16 +1,26 @@
import Controller from "../Controller";
import {NextFunction, Request, Response} from "express";
import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
import {BadRequestError} from "../HttpError";
import ModelFactory from "../db/ModelFactory";
import User from "./models/User";
import UserPasswordComponent from "./password/UserPasswordComponent";
import UserNameComponent from "./models/UserNameComponent";
import {UnknownRelationValidationError} from "../db/Validator";
import AuthMethod from "./AuthMethod";
import AuthProof from "./AuthProof";
import Controller from "../Controller.js";
import ModelFactory from "../db/ModelFactory.js";
import {UnknownRelationValidationError} from "../db/Validator.js";
import {BadRequestError} from "../HttpError.js";
import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
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';
}
@ -34,10 +44,12 @@ export default class AuthController extends Controller {
const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
res.render('auth/auth', {
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

@ -1,17 +1,18 @@
import AuthProof from "./AuthProof";
import MysqlConnectionManager from "../db/MysqlConnectionManager";
import User from "./models/User";
import {Connection} from "mysql";
import {Request, Response} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
import Mail from "../mail/Mail";
import Controller from "../Controller";
import config from "config";
import Application from "../Application";
import NunjucksComponent from "../components/NunjucksComponent";
import AuthMethod from "./AuthMethod";
import {Request, Response} from "express";
import {Session, SessionData} from "express-session";
import UserNameComponent from "./models/UserNameComponent";
import {Connection} from "mysql";
import Application from "../Application.js";
import {route} from "../common/Routing.js";
import MailComponent from "../components/MailComponent.js";
import MysqlConnectionManager from "../db/MysqlConnectionManager.js";
import Mail from "../mail/Mail.js";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails.js";
import AuthMethod from "./AuthMethod.js";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
import UserNameComponent from "./models/UserNameComponent.js";
export default class AuthGuard {
private readonly authMethods: AuthMethod<AuthProof<User>>[];
@ -145,12 +146,12 @@ export default class AuthGuard {
}
if (User.isApprovalMode()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
await this.app.as(MailComponent).sendMail(new Mail(PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user.asOptional(UserNameComponent)?.getName() ||
(await user.mainEmail.get())?.getOrFail('email') ||
'Could not find an identifier',
link: config.get<string>('public_url') + Controller.route('accounts-approval'),
}).send(config.get<string>('app.contact_email'));
link: route('accounts-approval', {}, {}, true),
}), config.get<string>('app.contact_email'));
}
}
@ -159,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

@ -1,8 +1,9 @@
import User from "./models/User";
import AuthProof from "./AuthProof";
import {Request, Response} from "express";
import {Session} from "express-session";
import AuthProof from "./AuthProof.js";
import User from "./models/User.js";
export default interface AuthMethod<P extends AuthProof<User>> {
/**

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

@ -1,19 +1,20 @@
import AuthMethod from "../AuthMethod";
import {Request, Response} from "express";
import User from "../models/User";
import UserEmail from "../models/UserEmail";
import MagicLink from "../models/MagicLink";
import {WhereTest} from "../../db/ModelQuery";
import Controller from "../../Controller";
import geoip from "geoip-lite";
import MagicLinkController from "./MagicLinkController";
import Application from "../../Application";
import {MailTemplate} from "../../mail/Mail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
import Validator, {EMAIL_REGEX} from "../../db/Validator";
import ModelFactory from "../../db/ModelFactory";
import UserNameComponent from "../models/UserNameComponent";
import {Session} from "express-session";
import geoip from "geoip-lite";
import Application from "../../Application.js";
import {route} from "../../common/Routing.js";
import ModelFactory from "../../db/ModelFactory.js";
import {WhereTest} from "../../db/ModelQuery.js";
import Validator, {EMAIL_REGEX} from "../../db/Validator.js";
import MailTemplate from "../../mail/MailTemplate.js";
import AuthMethod from "../AuthMethod.js";
import MagicLink from "../models/MagicLink.js";
import User from "../models/User.js";
import UserEmail from "../models/UserEmail.js";
import UserNameComponent from "../models/UserNameComponent.js";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType.js";
import MagicLinkController from "./MagicLinkController.js";
export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public constructor(
@ -54,7 +55,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
if (pendingLink) {
if (await pendingLink.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: req.getIntendedUrl() || pendingLink.original_url || undefined,
}));
return true;
@ -82,7 +83,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
}
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> {
const geo = geoip.lookup(req.ip);
const geo = req.ip ? geoip.lookup(req.ip) : null;
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
if (isRegistration) {
@ -104,7 +105,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
this.app,
req.getSession().id,
actionType,
Controller.route('auth', undefined, {
route('auth', undefined, {
redirect_uri: req.getIntendedUrl() || undefined,
}),
email,
@ -119,7 +120,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
res.redirect(route('magic_link_lobby', undefined, {
redirect_uri: req.getIntendedUrl(),
}));
}

View File

@ -1,24 +1,27 @@
import Controller from "../../Controller";
import {Request, Response} from "express";
import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener";
import {BadRequestError, NotFoundHttpError} from "../../HttpError";
import Throttler from "../../Throttler";
import Mail, {MailTemplate} from "../../mail/Mail";
import MagicLink from "../models/MagicLink";
import config from "config";
import Application from "../../Application";
import {ParsedUrlQueryInput} from "querystring";
import NunjucksComponent from "../../components/NunjucksComponent";
import User from "../models/User";
import AuthComponent, {AuthMiddleware} from "../AuthComponent";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import UserEmail from "../models/UserEmail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
import {QueryVariable} from "../../db/MysqlConnectionManager";
import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {logger} from "../../Logger";
import UserPasswordComponent from "../password/UserPasswordComponent";
import {Request, Response} from "express";
import Application from "../../Application.js";
import {QueryParamsRecord, route} from "../../common/Routing.js";
import MailComponent from "../../components/MailComponent.js";
import Controller from "../../Controller.js";
import {QueryVariable} from "../../db/MysqlConnectionManager.js";
import {BadRequestError, NotFoundHttpError} from "../../HttpError.js";
import {logger} from "../../Logger.js";
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";
import User from "../models/User.js";
import UserEmail from "../models/UserEmail.js";
import UserNameComponent from "../models/UserNameComponent.js";
import UserPasswordComponent from "../password/UserPasswordComponent.js";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType.js";
import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener.js";
export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(
@ -28,7 +31,7 @@ export default class MagicLinkController<A extends Application> extends Controll
original_url: string,
email: string,
mailTemplate: MailTemplate,
data: ParsedUrlQueryInput,
data: QueryParamsRecord,
magicLinkData: Record<string, QueryVariable> = {},
): Promise<void> {
Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(),
@ -46,12 +49,12 @@ export default class MagicLinkController<A extends Application> extends Controll
await link.save();
// Send email
await new Mail(app.as(NunjucksComponent).getEnvironment(), mailTemplate, Object.assign(data, {
link: `${config.get<string>('public_url')}${Controller.route('magic_link', undefined, {
await app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, {
link: `${route('magic_link', undefined, {
id: link.id,
token: token,
})}`,
})).send(email);
}, true)}`,
})), email);
}
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {
@ -88,7 +91,7 @@ export default class MagicLinkController<A extends Application> extends Controll
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect(Controller.route('auth'));
res.redirect(route('auth'));
return null;
} else {
throw e;
@ -114,33 +117,42 @@ export default class MagicLinkController<A extends Application> extends Controll
}
protected async getLobby(req: Request, res: Response): Promise<void> {
const link = await MagicLink.select()
const links = await MagicLink.select()
.where('session_id', req.getSession().id)
.sortBy('authorized')
.where('used', 0)
.first();
if (!link) {
.get();
if (links.length === 0) {
throw new NotFoundHttpError('magic link', req.url);
}
if (!await link.isValid()) {
req.flash('error', 'This magic link has expired. Please try again.');
res.redirect(link.getOrFail('original_url'));
let validLink;
for (const link of links) {
if (await link.isValid()) {
validLink = link;
} else {
req.flash('error', 'This magic link has expired. Please try again.');
await link.delete();
}
}
if (!validLink) {
res.redirect(req.getIntendedUrl() || route('home'));
return;
}
if (await link.isAuthorized()) {
link.use();
await link.save();
await this.performAction(link, req, res);
if (await validLink.isAuthorized()) {
validLink.useLink();
await validLink.save();
await this.performAction(validLink, req, res);
return;
}
res.render('magic_link_lobby', {
email: link.getOrFail('email'),
type: link.getOrFail('action_type'),
validUntil: link.getExpirationDate().getTime(),
websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath,
res.formatViewData('magic_link_lobby', {
email: validLink.getOrFail('email'),
type: validLink.getOrFail('action_type'),
validUntil: validLink.getExpirationDate().getTime(),
websocketUrl: config.get<string>('app.public_websocket_url') + this.magicLinkWebsocketPath,
});
}
@ -168,7 +180,7 @@ export default class MagicLinkController<A extends Application> extends Controll
}
}
res.render('magic_link', {
res.formatViewData('magic_link', {
magicLink: magicLink,
err: err,
success: success && err === null,
@ -187,9 +199,8 @@ 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}` : ''}`);
res.redirect(req.getIntendedUrl() || Controller.route('home'));
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
res.redirect(req.getIntendedUrl() || route('home'));
}
break;
}
@ -209,7 +220,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (await UserEmail.select().with('user').where('email', email).first()) {
req.flash('error', 'An account already exists with this email address.' +
' Please first remove it there before adding it here.');
res.redirect(Controller.route('account'));
res.redirect(route('account'));
return;
}
@ -226,7 +237,7 @@ export default class MagicLinkController<A extends Application> extends Controll
}
req.flash('success', `Email address ${userEmail.email} successfully added.`);
res.redirect(Controller.route('account'));
res.redirect(route('account'));
break;
}
@ -248,7 +259,7 @@ export default class MagicLinkController<A extends Application> extends Controll
}
req.flash('success', `Password successfully removed.`);
res.redirect(Controller.route('account'));
res.redirect(route('account'));
break;
}

Some files were not shown because too many files have changed in this diff Show More