Bring the repo up to speed with our recent developments

Summary:
Remove redundant files

User login fixes

Reviewers: #reviewers

Subscribers: jenkins-user

Differential Revision: https://hub.sealcode.org/D1253
master
Kuba Orlik 2 years ago
parent 9e3667398e
commit 651cd48220

@ -1,8 +1,8 @@
{
"phabricator.uri": "https://hub.sealcode.org/",
"arc.land.onto.default": "hotwire",
"arc.land.onto.default": "master",
"load": ["arcanist-linters", "arc-unit-mocha/src"],
"unit.engine": "MochaEngine",
"unit.mocha.include": ["./lib/**/*.test.js"],
"unit.mocha.dockerRoot": "/opt/sealious-playground"
"unit.mocha.dockerRoot": "/opt/sealious-app"
}

@ -13,13 +13,18 @@ module.exports = {
ecmaFeatures: {
modules: true,
},
project: ["./src/back/tsconfig.json", "./src/front/tsconfig.json"],
project: [
"./src/back/tsconfig.json",
"./src/front/tsconfig.json",
"./src/scripts/tsconfig.json",
],
},
rules: {
"@typescript-eslint/require-await": 0,
/* "jsdoc/require-description": 2, */
"no-await-in-loop": 2,
"@typescript-eslint/consistent-type-assertions": [2, { assertionStyle: "never" }],
"@typescript-eslint/consistent-type-assertions": [1, { assertionStyle: "never" }],
"no-console": 1,
},
settings: { jsdoc: { mode: "typescript" } },
overrides: [

2
.gitignore vendored

@ -24,3 +24,5 @@ coverage
/dist/
public/dist
/log.html
/hint-report/
.vscode

@ -0,0 +1,44 @@
{
"connector": {
"name": "jsdom"
},
"formatters": ["codeframe"],
"hintsTimeout": 20000,
"extends": ["web-recommended", "accessibility"],
"hints": {
"no-friendly-error-pages": "off",
"no-broken-links": "error",
"doctype": "error",
"apple-touch-icons": "error",
"button-type": "error",
"compat-api/css": "error",
"compat-api/html": "error",
"create-element-svg": "error",
"css-prefix-order": "error",
"disown-opener": "error",
"highest-available-document-mode": "error",
"leading-dot-classlist": "error",
"manifest-exists": "error",
"meta-charset-utf-8": "error",
"meta-viewport": "error",
"no-bom": "error",
"no-inline-styles": "error",
"no-protocol-relative-urls": "error",
"html-checker": "error",
"scoped-svg-styles": "error",
"sri": "error",
"axe/aria": "error",
"axe/color": "error",
"axe/forms": "error",
"axe/keyboard": "error",
"axe/language": "error",
"axe/name-role-value": "error",
"axe/parsing": "error",
"axe/semantics": "error",
"axe/sensory-and-visual-cues": "error",
"axe/structure": "error",
"axe/tables": "error",
"axe/text-alternatives": "error",
"axe/time-and-media": "error"
}
}

@ -0,0 +1,68 @@
{
"connector": "local",
"extends": ["web-recommended", "accessibility"],
"formatters": ["codeframe"],
"hints": {
"apple-touch-icons": "error",
"button-type": "error",
"compat-api/css": "error",
"compat-api/html": "error",
"create-element-svg": "error",
"css-prefix-order": "error",
"disown-opener": "error",
"highest-available-document-mode": "error",
"leading-dot-classlist": "error",
"manifest-exists": "error",
"meta-charset-utf-8": "off",
"meta-viewport": "error",
"no-bom": "error",
"no-inline-styles": "error",
"no-protocol-relative-urls": "error",
"scoped-svg-styles": "error",
"sri": "error",
"axe/aria": "error",
"axe/color": "error",
"axe/forms": "error",
"axe/keyboard": "error",
"axe/language": "error",
"axe/name-role-value": "error",
"axe/parsing": "error",
"axe/semantics": "error",
"axe/sensory-and-visual-cues": "error",
"axe/structure": "error",
"axe/tables": "error",
"axe/text-alternatives": "error",
"axe/time-and-media": "error",
"no-friendly-error-pages": "off",
"content-type": "off",
"http-cache": "off",
"http-compression": "off",
"no-disallowed-headers": "off",
"no-html-only-headers": "off",
"no-http-redirects": "off",
"no-vulnerable-javascript-libraries": "off",
"ssllabs": "off",
"strict-transport-security": "off",
"stylesheet-limits": "off",
"validate-set-cookie-header": "off",
"x-content-type-options": "off",
"no-broken-links": "off",
"typescript-config/consistent-casing": "off",
"typescript-config/is-valid": "off",
"typescript-config/strict": "off",
"typescript-config/target": "off"
},
"hintsTimeout": 10000,
"parsers": [
"babel-config",
"css",
"html",
"javascript",
"jsx",
"less",
"sass",
"typescript",
"typescript-config",
"webpack-config"
]
}

@ -1,23 +1,21 @@
# Sealious playground
# Sealious App
A simple todo app written in Sealious with a Hotwire-enhanced, server-side
rendered front-end.
## Running
## Installation
```
docker-compose up -d db
npm install
npm run watch
./npm.sh install
```
## Running on a custom port
Always use ./npm.sh when installing dependencies.
## Running the app in development mode
```
export SEALIOUS_PORT=8888
export SEALIOUS_BASE_URL="https://888.dep.sealcode.org"
npm run watch
```
If you want Sealious to send emails to mailcatcher and not log them in the console, add `SEALIOUS_MAILER=mailcatcher`
## Testing
```
./npm.sh run test
```

@ -3,18 +3,18 @@ services:
db:
image: mongo:4.4-bionic
ports:
- "127.0.0.1:20725:27017"
- "127.0.0.1:${PORT:-2074}7:27017"
test:
image: sealious-playground:latest
image: sealious-app:latest
build:
context: ./docker
dockerfile: ./test.Dockerfile
volumes:
- ./:/opt/sealious-playground/
- ~/.npm_cacache:/opt/sealious-playground/.npm_cacache
- ./:/opt/sealious-app/
- ~/.npm_cacache:/opt/sealious-app/.npm_cacache
user: ${UID:-1000}:${GID:-1000}
mailcatcher:
image: schickling/mailcatcher:latest
ports:
- "127.0.0.1:${PORT:-108}1:1080"
- "127.0.0.1:${PORT:-102}5:1025"
- "127.0.0.1:${PORT:-108}2:1080"
- "127.0.0.1:${PORT:-102}6:1025"

@ -2,7 +2,7 @@ FROM node:18-bullseye-slim
ENV UID=node \
GID=node \
HOME=/opt/fakturia
HOME=/opt/sealious-app
# Tini will ensure that any orphaned processes get reaped properly.
ENV TINI_VERSION v0.19.0

@ -9,7 +9,7 @@ const watch = process.argv.includes("--watch");
build({
entryPoints,
sourcemap: true,
outdir: "./dist",
outdir: "./dist/back",
logLevel: "info",
platform: "node",
watch,

@ -4,7 +4,6 @@ docker-compose down
cp secrets.example.json secrets.json
export SEALIOUS_PORT="${PORT}0"
SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
@ -12,12 +11,12 @@ export SEALIOUS_BASE_URL
# Create the npm cache directory if it isn't present yet. If it is not present, it will be created
# when the docker image is being built with root:root as the owner.
mkdir -p ~/.npm_cacache
docker-compose build
# Create .npm directory in the container, since it is not yet present and we need it for next step.
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-playground/.npm
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-app/.npm
# Link the host-bound npm cache directory into the container's npm cache directory.
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-playground/.npm_cacache /opt/sealious-playground/.npm/_cacache
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-app/.npm_cacache /opt/sealious-app/.npm/_cacache
docker-compose up -d db
./npm.sh ci
./npm.sh run build;
./npm.sh ci && ./npm.sh run build
rm -f log.html

@ -4,16 +4,14 @@ export SEALIOUS_PORT=$PORT
SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
./npm.sh run typecheck:front
./npm.sh run typecheck:back
./npm.sh run typecheck:front;
./npm.sh run typecheck:back;
docker-compose run --user="$UID"\
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
-e "SEALIOUS_PORT=$SEALIOUS_PORT" \
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
-e "SEALIOUS_SANITY=true" \
test
docker-compose run --user="$UID" \
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
-e "SEALIOUS_PORT=$SEALIOUS_PORT" \
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
-e "SEALIOUS_SANITY=true" \
test

@ -4,22 +4,21 @@ SEALIOUS_PORT="${PORT}0"
SEALIOUS_BASE_URL=$(cat .base_url)
export SEALIOUS_BASE_URL
./npm.sh run build:front;
./npm.sh run build:front
docker-compose up -d mailcatcher
docker-compose run --user="$UID"\
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
-e "SEALIOUS_PORT=$SEALIOUS_PORT" \
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
-e "SEALIOUS_MAILER=mailcatcher" \
-p "${SEALIOUS_PORT}:${SEALIOUS_PORT}" \
-d \
test \
/bin/sh -c "{ node . --color 2>&1; } | ./node_modules/.bin/ansi-html-stream > log.html" \
&& echo "App started on $SEALIOUS_PORT"
docker-compose run --user="$UID" \
-e "SEALIOUS_MONGO_PORT=27017" \
-e "SEALIOUS_MONGO_HOST=db" \
-e "SEALIOUS_PORT=$SEALIOUS_PORT" \
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
-e "SEALIOUS_MAILER=mailcatcher" \
-p "${SEALIOUS_PORT}:${SEALIOUS_PORT}" \
-d \
test \
/bin/sh -c "{ node . --color 2>&1; } | ./node_modules/.bin/ansi-html-stream > log.html" &&
echo "App started on $SEALIOUS_PORT"
echo "Deployed app to https://${SEALIOUS_PORT}.dep.sealco.de"
echo "Mailcatcher available at https://${PORT}1.dep.sealco.de"

@ -8,7 +8,5 @@ docker-compose run \
--user="$UID" \
--rm \
--service-ports \
-e BASELINE_DATABASE="baseline" \
-e "BASELINE_PORT=1433" \
test \
npm --loglevel warn "$@"
npm "$@"

28268
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,42 +1,50 @@
{
"name": "sealious-playground",
"version": "1.1.0",
"name": "sealious-app",
"version": "0.1.0",
"description": "",
"main": "./dist/index.js",
"main": "./dist/back/index.js",
"scripts": {
"start": "docker-compose up -d db && node .",
"typecheck:back": "tsc --noEmit -p src/back",
"typecheck:front": "tsc --noEmit -p src/front",
"build": "node ./esbuild.js",
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/**/*.test.ts",
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
"coverage": "nyc npm run test --",
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit"
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit",
"show-coverage": "npm run test-reports; xdg-open coverage/index.html"
},
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.12.10",
"@hotwired/turbo": "^7.1.0",
"@koa/router": "^10.0.0",
"@sealcode/sealgen": "^0.1.5",
"@sealcode/ts-predicates": "^0.1.1",
"@koa/router": "^10.1.1",
"@sealcode/sealgen": "^0.1.6",
"@sealcode/ts-predicates": "^0.4.0",
"esbuild-node-tsc": "^1.8.2",
"hint": "^7.0.1",
"locreq": "^2.0.2",
"merge": "^2.1.1",
"multiple-scripts-tmux": "^1.0.4",
"nodemon": "^2.0.7",
"sealious": "^0.14.2",
"sealious": "^0.14.10",
"source-map-support": "^0.5.21",
"stimulus": "^2.0.0",
"tempstream": "^0.0.7",
"wtfnode": "^0.9.1"
"tempstream": "^0.0.19"
},
"devDependencies": {
"@hint/connector-jsdom": "^4.1.20",
"@hint/formatter-codeframe": "^3.1.29",
"@hint/hint-doctype": "^3.3.19",
"@hint/hint-no-broken-links": "^4.2.19",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@sealcode/ansi-html-stream": "^1.0.1",
"@types/koa__router": "^8.0.4",
"@types/mocha": "^9.1.0",
"@types/tedious": "^4.0.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.2",
"axios": "^0.24.0",
"esbuild": "^0.14.10",
"esbuild-sass-plugin": "^2.0.0",
@ -72,5 +80,8 @@
"text-summary"
],
"report-dir": "coverage"
},
"engines": {
"node": ">=17.0.0"
}
}

@ -1,20 +1,14 @@
import _locreq from "locreq";
import { resolve } from "path";
import { App, LoggerMailer, SMTPMailer } from "sealious";
import tasks from "./collections/tasks";
import users from "./collections/users";
import PasswordResetIntents from "./collections/password-reset-intents";
import RegistrationIntents from "./collections/registration-intents";
import { UserRoles } from "./collections/user-roles";
import { LoggerLevel } from "sealious/@types/src/app/logger";
import { Secrets } from "./collections/secrets";
import { collections } from "./collections/collections";
const locreq = _locreq(__dirname);
const PORT = process.env.SEALIOUS_PORT ? parseInt(process.env.SEALIOUS_PORT) : 8080;
const base_url = process.env.SEALIOUS_BASE_URL || `http://localhost:${PORT}`;
const MONGO_PORT = process.env.SEALIOUS_MONGO_PORT
? parseInt(process.env.SEALIOUS_MONGO_PORT)
: 20725;
: 20726;
const MONGO_HOST = process.env.SEALIOUS_MONGO_HOST || "127.0.0.1";
export default class TheApp extends App {
@ -23,13 +17,14 @@ export default class TheApp extends App {
datastore_mongo: {
host: MONGO_HOST,
port: MONGO_PORT,
db_name: "sealious-playground",
db_name: "sealious-app",
},
email: {
from_address: "sealious-playground@example.com",
from_name: "Sealious playground app",
from_address: "sealious-app@example.com",
from_name: "sealious-app app",
},
logger: {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
level: "info" as LoggerLevel,
},
"www-server": {
@ -40,7 +35,7 @@ export default class TheApp extends App {
},
};
manifest = {
name: "Sealious Playground",
name: "sealious-app",
logo: locreq.resolve("assets/logo.png"),
version: "0.0.1",
default_language: "en",
@ -50,15 +45,7 @@ export default class TheApp extends App {
primary: "#5294a1",
},
};
collections = {
...App.BaseCollections,
users,
"registration-intents": new RegistrationIntents(),
"password-reset-intents": new PasswordResetIntents(),
"user-roles": new UserRoles(),
tasks,
secrets: new Secrets(),
};
collections = collections;
mailer =
process.env.SEALIOUS_MAILER === "mailcatcher"
? new SMTPMailer({
@ -68,4 +55,14 @@ export default class TheApp extends App {
password: "any",
})
: new LoggerMailer();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async start() {
await super.start();
}
async stop() {
await super.stop();
}
}

@ -1,6 +0,0 @@
describe("collections", () => {
require("./password-reset-intents.subtest");
require("./registration-intents.subtest");
require("./user-roles.subtest");
require("./users.subtest");
});

@ -0,0 +1,26 @@
// DO NOT EDIT! This file is generated automaticaly with 'npm run generate-collections'
import { App } from "sealious";
import _GroupsToUsers from "./groups-to-users";
import _Groups from "./groups";
import _PasswordResetIntents from "./password-reset-intents";
import _Secrets from "./secrets";
import _UserRoles from "./user-roles";
import _Users from "./users";
export const GroupsToUsers = new _GroupsToUsers();
export const Groups = new _Groups();
export const PasswordResetIntents = new _PasswordResetIntents();
export const Secrets = new _Secrets();
export const UserRoles = new _UserRoles();
export const Users = new _Users();
export const collections = {
...App.BaseCollections,
"groups-to-users": GroupsToUsers,
groups: Groups,
"password-reset-intents": PasswordResetIntents,
secrets: Secrets,
"user-roles": UserRoles,
users: Users,
};

@ -0,0 +1,20 @@
import { Collection, FieldTypes, Policies } from "sealious";
import { Roles } from "../policy-types/roles";
export default class GroupsToUsers extends Collection {
fields = {
user: new FieldTypes.SingleReference("users"),
group: new FieldTypes.SingleReference("groups"),
};
defaultPolicy = new Roles(["admin"]);
policies = {
show: new Policies.Or([
new Roles(["admin"]),
new Policies.UserReferencedInField("user"),
]),
list: new Policies.Or([
new Roles(["admin"]),
new Policies.UserReferencedInField("user"),
]),
};
}

@ -0,0 +1,23 @@
import { Collection, FieldTypes, Policies } from "sealious";
import { Roles } from "../policy-types/roles";
export default class Groups extends Collection {
fields = {
name: new FieldTypes.Text(),
};
defaultPolicy = new Policies.LoggedIn();
policies = {
create: new Roles(["admin"]),
edit: new Roles(["admin"]),
};
async populate(): Promise<void> {
if (await this.app.Metadata.get("groups_populated")) {
return;
}
// eslint-disable-next-line no-console
console.log("### Populating groups");
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
await this.app.Metadata.set("groups_populated", "true");
}
}

@ -1,6 +1,8 @@
import { App, Collection, CollectionItem, Context, FieldTypes, Policies } from "sealious";
import assert from "assert";
import PasswordResetTemplate from "../email-templates/password-reset";
import TheApp from "../app";
import { assertType, predicates } from "@sealcode/ts-predicates";
export default class PasswordResetIntents extends Collection {
name = "password-reset-intents";
@ -16,21 +18,29 @@ export default class PasswordResetIntents extends Collection {
create: new Policies.Public(),
edit: new Policies.Noone(),
};
defaultPolicy: Policies.Super;
defaultPolicy = new Policies.Super();
async init(app: App, name: string) {
const theApp = app as TheApp;
assert(app instanceof TheApp);
await super.init(app, name);
app.collections["password-reset-intents"].on(
"after:create",
async ([context, intent]: [
async ([, intent]: [
Context,
CollectionItem<PasswordResetIntents>,
any
unknown
]) => {
const intent_as_super = await intent.fetchAs(new app.SuperContext());
const message = await PasswordResetTemplate(theApp, {
email_address: intent.get("email") as string,
token: intent_as_super.get("token") as string,
const message = await PasswordResetTemplate(app, {
email_address: assertType(
intent.get("email"),
predicates.string,
"email_address isn't a string"
),
token: assertType(
intent_as_super.get("token"),
predicates.string,
"token isn't a string"
),
});
await message.send(app);
}

@ -1,48 +0,0 @@
import axios from "axios";
import assert from "assert";
import { TestUtils, Policies } from "sealious";
import { withProdApp } from "../test_utils/with-prod-app";
describe("registration-intents", () => {
it("doesn't allow setting a role for registration intention when the user in context can't create user-roles", async () =>
withProdApp(async ({ app, base_url }) => {
app.collections["user-roles"].setPolicy("create", new Policies.Noone());
await TestUtils.assertThrowsAsync(
() =>
axios.post(`${base_url}/api/v1/collections/registration-intents`, {
email: "cunning@fox.com",
role: "admin",
}),
(e: any) => {
assert.equal(
e.response.data.data.field_messages.role.message,
app.i18n("policy_users_who_can_deny", [
"create",
"user-roles",
app.i18n("policy_noone_deny"),
])
);
}
);
}));
it("allows setting a role for registration intention when the user in context can create user-roles", async () =>
withProdApp(async ({ app, base_url }) => {
app.collections["user-roles"].setPolicy("create", new Policies.Public());
const intent = (
await axios.post(`${base_url}/api/v1/collections/registration-intents`, {
email: "genuine@fox.com",
role: "admin",
})
).data;
assert.equal(intent.role, "admin");
const role = (
await app.collections["registration-intents"].suGetByID(
intent.id as string
)
).get("role");
assert.equal(role, "admin");
}));
});

@ -1,43 +0,0 @@
import { App, Collection, FieldTypes, Policies } from "sealious";
import RegistrationIntentTemplate from "../email-templates/registration-intent";
export default class RegistrationIntents extends Collection {
fields = {
email: new FieldTypes.ValueNotExistingInCollection({
collection: "users",
field: "email",
include_forbidden: true,
}),
token: new FieldTypes.SecretToken(),
role: new FieldTypes.SettableBy(
new FieldTypes.Enum((app: App) => app.ConfigManager.get("roles")),
new Policies.UsersWhoCan(["create", "user-roles"])
),
};
policies = {
create: new Policies.Public(),
edit: new Policies.Noone(),
};
defaultPolicy = new Policies.Super();
async init(app: App, name: string) {
await super.init(app, name);
this.on("after:create", async ([context, intent]) => {
await intent.decode(context);
const {
items: [item],
} = await app.collections["registration-intents"]
.suList()
.ids([intent.id])
.fetch();
const token = item.get("token") as string;
const message = await RegistrationIntentTemplate(app, {
email_address: intent.get("email") as string,
token,
});
await message.send(app);
});
}
}

@ -2,11 +2,9 @@ import { Collection, FieldTypes } from "sealious";
import { Roles } from "../policy-types/roles";
/* For testing the Roles policy */
export class Secrets extends Collection {
export default class Secrets extends Collection {
fields = {
content: new FieldTypes.Text(),
};
defaultPolicy = new Roles(["admin"]);
}
export default new Secrets();

@ -1,16 +0,0 @@
import { Collection, FieldTypes, Policies } from "sealious";
export class Tasks extends Collection {
fields = {
title: new FieldTypes.Text(),
done: new (class extends FieldTypes.Boolean {
hasDefaultValue = () => true;
async getDefaultValue() {
return false;
}
})(),
};
defaultPolicy = new Policies.Public();
}
export default new Tasks();

@ -1,36 +1,9 @@
import assert from "assert";
import axios from "axios";
import { CollectionItem, TestUtils } from "sealious";
import { Users } from "./users";
import TheApp from "../app";
import { Context, TestUtils } from "sealious";
import { withProdApp } from "../test_utils/with-prod-app";
function createAUser(app: TheApp, username: string) {
return app.collections.users.suCreate({
username,
email: `${username}@example.com`,
password: "password",
roles: [],
});
}
type Unpromisify<T> = T extends Promise<infer R> ? R : T;
async function createAdmin(
app: TheApp,
rest_api: TestUtils.MockRestApi
): Promise<[CollectionItem<Users>, Unpromisify<ReturnType<typeof rest_api.login>>]> {
const user = await createAUser(app, "super_user");
await app.collections["user-roles"].suCreate({
user: user.id,
role: "admin",
});
const session = await rest_api.login({
username: "super_user",
password: "password",
});
return [user, session];
}
import { createAdmin, createAUser } from "../test_utils/users";
import Users from "./users";
describe("user-roles", () => {
it("rejects when given an empty role", async () =>
@ -68,4 +41,30 @@ describe("user-roles", () => {
);
assert.equal(response.status, 201);
}));
it("get user roles with admin", async () =>
withProdApp(async ({ app, rest_api }) => {
const [user] = await createAdmin(app, rest_api);
const roles = await Users.getRoles(
new Context(app, new Date().getTime(), user.id)
);
assert.ok(roles.includes("admin"));
}));
it("get user with no roles", async () =>
withProdApp(async ({ app }) => {
const user = await createAUser(app, "normal");
const roles = await Users.getRoles(
new Context(app, new Date().getTime(), user.id)
);
assert.ok(roles.length === 0);
}));
it("get no roles for no logged user", async () =>
withProdApp(async ({ app }) => {
const roles = await Users.getRoles(
new Context(app, new Date().getTime(), null)
);
assert.ok(roles.length === 0);
}));
});

@ -1,7 +1,7 @@
import { ActionName, App, Collection, FieldTypes, Policies, Policy } from "sealious";
import { App, Collection, FieldTypes, Policies, Policy } from "sealious";
import { Roles } from "../policy-types/roles";
export class UserRoles extends Collection {
export default class UserRoles extends Collection {
name = "user-roles";
fields = {
role: new FieldTypes.Enum((app: App) =>
@ -10,6 +10,7 @@ export class UserRoles extends Collection {
user: new FieldTypes.SingleReference("users"),
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
policies = {
create: new Roles(["admin"]),
delete: new Policies.Public(),
@ -21,7 +22,7 @@ export class UserRoles extends Collection {
await super.init(app, collection_name);
app.on("started", async () => {
const roles = app.collections["user-roles"];
for (const action of ["create", "delete"] as ActionName[]) {
for (const action of <const>["create", "delete"]) {
const policy = roles.getPolicy(action);
if (policy instanceof Policies.Public) {
app.Logger.warn(
@ -33,5 +34,3 @@ export class UserRoles extends Collection {
});
}
}
export default new UserRoles();

@ -1,20 +0,0 @@
import assert from "assert";
import { withProdApp } from "../test_utils/with-prod-app";
describe("users", () => {
it.skip("should properly handle route to account creation", async () =>
withProdApp(async ({ app, rest_api }) => {
const sealious_response = await app.collections["registration-intents"]
.suList()
.filter({ email: app.manifest.admin_email })
.fetch();
const { email, token } = sealious_response.items[0].serializeBody();
const response = await rest_api.get(
`/account-creation-details?token=${token as string}&email=${
email as string
}`
);
assert(response.includes("Please fill in the details of your account"));
}));
});

@ -1,7 +1,8 @@
import { App, Collections, FieldTypes } from "sealious";
import { App, Collections, Context, FieldTypes, Policies } from "sealious";
import assert from "assert";
import TheApp from "../app";
export class Users extends Collections.users {
export default class Users extends Collections.users {
fields = {
...App.BaseCollections.users.fields,
email: new FieldTypes.Email().setRequired(true),
@ -11,7 +12,10 @@ export class Users extends Collections.users {
}),
};
async init(app: TheApp, name: string) {
defaultPolicy = new Policies.Themselves();
async init(app: App, name: string) {
assert(app instanceof TheApp);
await super.init(app, name);
app.on("started", async () => {
const users = await app.collections.users
@ -23,30 +27,22 @@ export class Users extends Collections.users {
"ADMIN",
`Creating an admin account for ${app.manifest.admin_email}`
);
await app.collections["registration-intents"].suCreate({
email: app.manifest.admin_email,
role: "admin",
token: "",
await app.collections.users.suCreate({
username: "admin",
password: "adminadmin",
email: "admin@example.com",
roles: [],
});
}
});
}
async populate(): Promise<void> {
if (await this.app.Metadata.get("my_collection_populated")) {
return;
}
const app = this.app as TheApp;
await app.collections.users.suCreate({
email: "admin@example.com",
roles: [],
username: "admin",
password: "password",
});
public static async getRoles(ctx: Context) {
const rolesEntries = await ctx.app.collections["user-roles"]
.list(ctx)
.filter({ user: ctx.user_id || "" })
.fetch();
await this.app.Metadata.set("my_collection_populated", "true");
return rolesEntries.items.map((item) => item.get("role"));
}
}
export default new Users();

@ -0,0 +1,344 @@
import { is, predicates } from "@sealcode/ts-predicates";
import { BaseContext } from "koa";
import { Templatable, tempstream } from "tempstream";
import { ChekboxedListField, FormField, PickFromListField } from "./field";
import Form, { FormData } from "./form";
import { FormFieldsList } from "./form-fields-list";
export abstract class FormControl {
abstract render(
ctx: BaseContext,
formFields: FormField[],
data: FormData
): Templatable | Promise<Templatable>;
abstract role: "input" | "decoration" | "messages" | "submit";
}
export class FormHeader extends FormControl {
role = <const>"decoration";
constructor(
public text: string,
public isVisible: (ctx: BaseContext) => Promise<boolean> = async () => true
) {
super();
}
async render(ctx: BaseContext) {
const isVsbl = await this.isVisible(ctx);
return isVsbl ? `<h2>${this.text}</h2>` : "";
}
}
export class FormParagraph extends FormControl {
role = <const>"decoration";
constructor(public text: string) {
super();
}
render() {
return `<p>${this.text}</p>`;
}
}
export abstract class FormFieldControl extends FormControl {
role = <const>"input";
constructor(public fieldnames: string[]) {
super();
}
areFieldNamesValid(fields: FormField[]) {
return this.fieldnames.every((fieldname) =>
fields.some((f) => f.name == fieldname)
);
}
abstract _render(
ctx: BaseContext,
fields: FormField[],
data: FormData
): Templatable | Promise<Templatable>;
render(
ctx: BaseContext,
fields: FormField[],
data: FormData
): Templatable | Promise<Templatable> {
if (!this.areFieldNamesValid(fields)) {
throw new Error(
`Invalid field names given to form control: "${this.fieldnames.join(
", "
)}". Allowed fields are: ${fields.map((f) => f.name).join(", ")}`
);
}
return this._render(ctx, fields, data);
}
}
export class SimpleInput extends FormFieldControl {
constructor(
public fieldname: string,
public options: {
id?: string;
label?: string;
autocomplete?: boolean;
type?:
| "color"
| "date"
| "email"
| "file"
| "month"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url"
| "week";
value?: string;
placeholder?: string;
readonly?: boolean;
step?: number;
} = {}
) {
super([fieldname]);
}
_render(_: BaseContext, fields: FormField[], data: FormData) {
const field = FormFieldsList.getField(fields, this.fieldname);
if (!field) {
throw new Error("wrong field name");
}
const id = this.options.id || field.name;
const label = this.options.label || field.name;
const type = this.options.type || "text";
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const value = data.values[field.name] as string;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const raw_value = data.raw_values[field.name] as string;
const placeholder = this.options.placeholder || type;
const readonly = this.options.readonly || false;
const required = field.required;
const error = data.errors[field.name];
return /* HTML */ `<div class="input">
<label for="${id}">${label}</label>
<input
id="${id}"
type="${type}"
name="${field.name}"
value="${value === undefined
? raw_value == undefined
? ""
: raw_value
: value}"
placeholder="${placeholder}"
${readonly ? "readonly" : ""}
${required ? "required" : ""}
${!this.options.autocomplete ? `autocomplete="off"` : ""}
${this.options.step ? `step="${this.options.step}"` : ""}
/>
${error ? `<div class="input__error">${error}</div>` : ""}
</div>`;
}
}
export class Dropdown extends FormFieldControl {
constructor(
public fieldname: string,
public options: {
label: string;
autosubmit?: boolean;
autocomplete?: boolean;
} = {
label: fieldname,
autosubmit: false,
autocomplete: true,
}
) {
super([fieldname]);
}
areFieldNamesValid(fields: FormField[]) {
return (
super.areFieldNamesValid(fields) &&
FormFieldsList.getField(fields, this.fieldname) instanceof PickFromListField
);
}
_render(ctx: BaseContext, fields: FormField[], data: FormData) {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as PickFromListField;
const picked_value = data.values[field.name] || "";
const id = field.name;
return tempstream/* HTML */ `<label for="${id}">${this.options.label}</label
><select
name="${this.fieldnames}"
id="${id}"
${this.options.autosubmit ? `onchange='this.form.submit()'` : ""}
${!this.options.autocomplete ? `autocomplete="off"` : ""}
>
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options).map(
([value, text]) =>
`<option value="${value}" ${
(value || "") == picked_value ? "selected" : ""
}>${text}</option>`
)
)}
</select>`;
}
}
export class CheboxedListInput extends FormFieldControl {
constructor(
public fieldname: string,
public options: { label: string } = { label: fieldname }
) {
super([fieldname]);
}
isValidFieldName(form: Form) {
return form.fields.some((f) => f.name == this.fieldname);
}
async _render(ctx: BaseContext, fields: FormField[], data: FormData) {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as ChekboxedListField;
const pickedValues = data.values[field.name] || "";
if (!is(pickedValues, predicates.array(predicates.string))) {
throw new Error("picked values is not an array of strings");
}
const [options, isVisible] = await Promise.all([
field.generateOptions(ctx),
field.isVisible(ctx),
]);
return tempstream/* HTML */ `${isVisible
? Object.entries(options).map(
([value, text]) => /* HTML */ `<div>
<input
type="checkbox"
id="${field.name}.${value}"
name="${field.name}.${value}"
${pickedValues.includes(value) ? "checked" : ""}
/>
<label for="${field.name}.${value}">${text}</label>
</div>`
)
: ""}`;
}
}
/**
* This class will render `turbo-frame` tag so that u can
* embed other route inside your form. This will require
* to add value to `data: FormData` (inside your master form
* render function) with key `Frame.FRAME_PATH_KEY`. Value
* needs to be url to route that you want to embed. If you
* this value wont be provided frame will redner empty string.
* See `src/back/routes/profile/[id].form.ts` for an example.
*/
export class Frame extends FormControl {
constructor(public src: string) {
super();
}
render(): Templatable | Promise<Templatable> {
return /* HTML */ `<turbo-frame
id="contrahents"
loading="lazy"
src="${this.src}"
></turbo-frame>`;
}
role = <const>"decoration";
}
/**
* This control has own forms in it so if you want to use it you
* probably shouldn't use `await super.render(ctx, data, path)` in
* render method of you from implementation and you should write
* your own implementation of this method. See forms that uses
* this control for reference.
*/
export class EditableCollectionSubset extends FormFieldControl {
constructor(
public fieldname: string,
public actionname: string,
public listLabel?: string,
public selectLabel?: string
) {
super([fieldname]);
}
_render(
ctx: BaseContext,
fields: FormField[],
data: FormData<string>
): Templatable | Promise<Templatable> {
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const field = FormFieldsList.getField(
fields,
this.fieldname
) as PickFromListField;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const values = data.values[this.fieldname] as string[];
return tempstream/* HTML */ `<div>
<ul>
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options)
.filter(([value]) => values.includes(value))
.map(
([value, text]) => /* HTML */ `
<li>
<form method="POST" action="${ctx.path}">
<span>${text}</span>
<input
type="hidden"
name="${this.fieldname}"
value="${value}"
/>
<input
type="hidden"
name="${this.actionname}"
value="list"
/>
<input
type="submit"
${this.listLabel
? `value="${this.listLabel}"`
: ""}
/>
</form>
</li>
`
)
)}
</ul>
<form method="POST" action="${ctx.path}">
<select name="${this.fieldname}">
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
Object.entries(options)
.filter(([value]) => !values.includes(value))
.map(
([value, text]) =>
`<option value="${value}">${text}</option>`
)
)}
</select>
<input type="hidden" name="${this.actionname}" value="select" />
<input
type="submit"
${this.selectLabel ? `value="${this.selectLabel}"` : ""}
/>
</form>
</div>`;
}
role = <const>"input";
}

@ -0,0 +1,119 @@
import { is, predicates } from "@sealcode/ts-predicates";
import { BaseContext } from "koa";
export type FormFieldValidationResponse = { valid: boolean; message: string };
export type FormFieldValidationFn = (
ctx: BaseContext,
value: unknown,
field: FormField
) => Promise<FormFieldValidationResponse>;
export class FormField<Fieldnames extends string = string> {
constructor(
public name: Fieldnames,
public required: boolean = false,
public validator: FormFieldValidationFn = async () => ({
valid: true,
message: "",
})
) {}
public async _validate(
ctx: BaseContext,
value: unknown
): Promise<FormFieldValidationResponse> {
if (this.required && (value == "" || value == null || value == undefined)) {
return { valid: false, message: "This field is required" };
}
return this.validator(ctx, value, this);
}
public getEmptyValue() {
return "";
}
}
export class PickFromListField<
Fieldnames extends string = string
> extends FormField<Fieldnames> {
constructor(
public name: Fieldnames,
public required: boolean = false,
public generateOptions: (
ctx: BaseContext
) => Promise<Record<string, string> | { [i: string]: string }>,
public customValidation: (
ctx: BaseContext,
value: unknown,
instance: PickFromListField
) => Promise<FormFieldValidationResponse> = (ctx, value, instance) =>
instance.valueInList(ctx, value)
) {
super(name, required, (ctx, value) => this.customValidation(ctx, value, this));
}
async valueInList(
ctx: BaseContext,
value: unknown
): Promise<FormFieldValidationResponse> {
const options = await this.generateOptions(ctx);
if (!is(value, predicates.string)) {
return { valid: false, message: "not a string" };
}
if (!Object.keys(options).includes(value)) {
return { valid: false, message: `"${value}" is not one of the options` };
}
return { valid: true, message: "" };
}
}
export class ChekboxedListField<
Fieldnames extends string = string
> extends FormField<Fieldnames> {
constructor(
public name: Fieldnames,
public required: boolean = false,
public generateOptions: (
ctx: BaseContext
) => Promise<Record<string, string> | { [i: string]: string }>,
public isVisible: (ctx: BaseContext) => Promise<boolean> = () =>
Promise.resolve(true)
) {
super(name, required, (ctx, value) => this.isValueValid(ctx, value));
}
private async isValueValid(
_: BaseContext,
value: unknown
): Promise<FormFieldValidationResponse> {
if (is(value, predicates.string)) {
return { valid: false, message: "you need an array" };
}
if (is(value, predicates.null)) {
return { valid: false, message: "you need an array" };
}
return { valid: true, message: "" };
}
}
export class NumberField<
Fieldnames extends string = string
> extends FormField<Fieldnames> {
constructor(field_name: Fieldnames, required: boolean) {
super(field_name, required, (_, value) => this.isValueValid(_, value));
}
private async isValueValid(_: BaseContext, value: unknown) {
if (
(is(value, predicates.string) &&
!isNaN(parseFloat(value)) &&
parseFloat(value).toString() == value.trim()) ||
is(value, predicates.number) ||
((is(value, predicates.undefined) || value == "") && !this.required)
) {
return { valid: true, message: "" };
}
return { valid: false, message: "Proszę wprowadzić liczbę" };
}
}

@ -0,0 +1,7 @@
import { FormField } from "./field";
export class FormFieldsList {
static getField(fields: FormField[], name: string) {
return fields.find((f) => f.name == name);
}
}

@ -0,0 +1,231 @@
import { BaseContext } from "koa";
import Router from "@koa/router";
import { Templatable, tempstream } from "tempstream";
import { FormControl } from "./controls";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { Mountable, PageErrorMessage } from "../page/page";
import { FormField } from "./field";
export type FormData<Fieldnames extends string = string> = {
values: Record<Fieldnames, string | string[] | number>;
raw_values: Record<Fieldnames, string | string[] | number>;
errors: Partial<Record<Fieldnames, string>>;
messages: { type: "info" | "success" | "error"; text: string }[];
};
export default abstract class Form<Fieldnames extends string = string>
implements Mountable
{
abstract fields: FormField<Fieldnames>[];
abstract controls: FormControl[];
defaultSuccessMessage = "Done";
submitButtonText = "Wyslij";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: BaseContext) {
return { canAccess: true, message: "" };
}
async renderError(_: BaseContext, error: PageErrorMessage) {
return tempstream/* HTML */ `<div>${error.message}</div>`;
}
async validate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_: BaseContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
__: Record<string, unknown>
): Promise<{ valid: boolean; error: string }> {
return {
valid: true,
error: "",
};
}
private async _validate(
ctx: BaseContext,
values: Record<string, unknown>
): Promise<{
valid: boolean;
errors: Record<Fieldnames | "form", string>;
}> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const errors = {} as Record<Fieldnames | "form", string>;
let valid = true;
await Promise.all(
this.fields.map(async (field) => {
const { valid: fieldvalid, message: fieldmessage } =
await field._validate(ctx, values[field.name]);
if (!fieldvalid) {
valid = false;
errors[field.name] = fieldmessage;
}
})
);
const formValidationResult = await this.validate(ctx, values);
if (!formValidationResult.valid) {
valid = false;
errors.form = formValidationResult.error;
}
return { valid, errors };
}
async render(
ctx: BaseContext,
data: FormData,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
form_path: string
): Promise<Templatable> {
return tempstream/* HTML */ `${this.makeFormTag(`${ctx.URL.pathname}/`)} ${
!this.controls.some((control) => control.role == "messages")
? this.renderMessages(ctx, data)
: ""
} ${
data.errors.form !== undefined
? `<div class="form__error">${data.errors.form}</div>`
: ""
} ${this.renderControls(ctx, data)}<input type="submit" value="${
this.submitButtonText
}"/></form>`;
}
public renderMessages(_: BaseContext, data: FormData): Templatable {
return tempstream/* HTML */ `<div class="form-messages">
${data.messages.map(
(message) =>
`<div class="form-message form-message--${message.type}">${message.text}</div>`
)}
</div>`;
}
public renderControls(ctx: BaseContext, data: FormData): Templatable {
return tempstream/* HTML */ `${this.controls.map((control) =>
control.render(ctx, this.fields, data)
)}`;
}
public makeFormTag(path: string) {
return `<form method="POST" action="${path}">`;
}
private generateData(rawData: Record<string, unknown> = {}): FormData {
// generates a FormData object that has the correct shape to be passed to
// render(), so for example it makes sure that all fields either have values or
// are empty string (the aren't undefined, for example). If no argument is passed,
// creates an object that represents an empty state of the form. If some data
// object is passed in the first argument, then the values in that data object are
// incorporated into the generated object
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const values = Object.fromEntries(
this.fields.map((f) => [f.name, rawData[f.name] || f.getEmptyValue()])
) as Record<Fieldnames, string>;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const errors = Object.fromEntries(this.fields.map((f) => [f.name, ""])) as Record<
Fieldnames,
string
>;
return {
values,
errors,
messages: [],
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
raw_values: rawData as Record<string, string>,
};
}
public async onValuesInvalid(
ctx: BaseContext,
errors: Record<Fieldnames, string>,
form_path: string
) {
ctx.status = 422;
const { values, raw_values } = this.generateData(ctx.$body);
ctx.body = await this.render(
ctx,
{
values,
raw_values,
errors,
messages: [{ type: "error", text: "Some fields are invalid" }],
},
form_path
);
}
public async onError(ctx: BaseContext, error: unknown, form_path: string) {
ctx.status = 422;
let error_message = "Unknown error has occured";
if (
is(error, predicates.object) &&
hasShape({ message: predicates.string }, error)
) {
error_message = error.message;
}
const { values, raw_values } = this.generateData(ctx.$body);
ctx.body = await this.render(
ctx,
{
values,
raw_values,
errors: {},
messages: [{ type: "error", text: error_message }],
},
form_path
);
}
public abstract onSubmit(
ctx: BaseContext,
values: Record<Fieldnames, string | string[] | number>
): void | Promise<void>;
public async onSuccess(ctx: BaseContext, form_path: string): Promise<void> {
const { values, raw_values } = this.generateData(ctx.$body);
ctx.body = await this.render(
ctx,
{
values,
raw_values,
errors: {},
messages: [{ type: "success", text: this.defaultSuccessMessage }],
},
form_path
);
ctx.status = 422;
}
public mount(router: Router, path: string) {
router.use(path, async (ctx, next) => {
const result = await this.canAccess(ctx);
if (!result.canAccess) {
ctx.body = this.renderError(ctx, {
type: "access",
message: result.message,
});
ctx.status = 403;
return;
}
await next();
});
router.get(path, async (ctx) => {
ctx.type = "html";
ctx.body = await this.render(ctx, this.generateData(), path);
});
router.post(path, async (ctx) => {
const { valid, errors } = await this._validate(ctx, ctx.$body);
if (!valid) {
await this.onValuesInvalid(ctx, errors, path);
return;
}
try {
await this.onSubmit(ctx, this.generateData(ctx.$body).values);
await this.onSuccess(ctx, path);
} catch (e) {
// eslint-disable-next-line no-console
console.dir(e, { depth: 5 });
await this.onError(ctx, e, path);
}
});
}
}

@ -0,0 +1,18 @@
.form-message {
--color: black;
border: 1px solid var(--color);
border-radius: 5px;
padding: 10px;
&--error {
--color: #ff4136;
}
&--success {
--color: #2ecc40;
}
}
.form__error {
color: #ff4136;
}

@ -0,0 +1,10 @@
import { BaseContext } from "koa";
import { Field } from "sealious";
import { FormFieldValidationFn } from "./field";
export function collectionFieldValidator(field: Field): FormFieldValidationFn {
return async (ctx: BaseContext, value) => {
const { valid, reason } = await field.checkValue(ctx.$context, value, undefined);
return { valid, message: reason || (valid ? "Wrong value" : "") };
};
}

@ -1,16 +1,29 @@
import { Templatable, tempstream } from "tempstream";
import { Readable } from "stream";
import { BaseContext } from "koa";
import navbar from "./routes/common/navbar";
export default function html(ctx: BaseContext, body: Templatable): Readable {
export const defaultHead = (ctx: BaseContext, title: string) => /* HTML */ `<title>
${title} · ${ctx.$app.manifest.name}
</title>
<meta name="viewport" content="width=device-width" />
<script async src="/dist/bundle.js"></script>
<link href="/dist/style.css" rel="stylesheet" type="text/css" />`;
export default function html(
ctx: BaseContext,
title: string,
body: Templatable,
makeHead: (ctx: BaseContext, title: string) => Templatable = defaultHead
): Readable {
ctx.set("content-type", "text/html;charset=utf-8");
return tempstream/* HTML */ ` <!DOCTYPE html>
<html>
<html lang="pl">
<head>
<meta name="viewport" content="width=device-width" />
<script async src="/dist/bundle.js"></script>
<link href="/dist/style.css" rel="stylesheet" type="text/css" />
${makeHead(ctx, title)}
</head>
${body}
<body>
${navbar(ctx)} ${body}
</body>
</html>`;
}

@ -16,7 +16,6 @@ const app = new TheApp();
app.start()
.then(async () => {
await app.collections.users.populate();
if (process.env.SEALIOUS_SANITY === "true") {
console.log("Exiting with error code 0");
process.exit(0);

@ -0,0 +1,18 @@
import { BaseContext } from "koa";
import { ActionName, Collection } from "sealious";
export function peopleWhoCan(action: ActionName, collection: Collection) {
return async function (ctx: BaseContext) {
const policy = collection.getPolicy(action);
const result = await policy.check(ctx.$context);
if (!result) {
ctx.status = 403;
return { canAccess: false, message: "Not allowed" };
}
if (!result.allowed) {
ctx.status = 403;
return { canAccess: false, message: result.reason };
}
return { canAccess: true, message: "" };
};
}

@ -0,0 +1,200 @@
import { BaseContext } from "koa";
import { Collection, CollectionItem } from "sealious";
import { Templatable, tempstream } from "tempstream";
import { peopleWhoCan } from "./access-control";
import { naturalNumbers, UrlWithNewParams } from "../util";
import { Page } from "./page";
import { predicates, ShapeToType } from "@sealcode/ts-predicates";
import { PagePropsParser } from "./props-parser";
import { FormFieldControl } from "../forms/controls";
import { FormField } from "../forms/field";
import { FormData } from "../forms/form";
import { FormFieldsList } from "../forms/form-fields-list";
export const BasePagePropsShape = <const>{};
export type BasePageProps = ShapeToType<typeof BasePagePropsShape>;
export const BaseListPagePropsShape = <const>{
page: predicates.number,
itemsPerPage: predicates.number,
};
export type BaseListPageProps = ShapeToType<typeof BaseListPagePropsShape>;
export const BaseListPageDefaultProps = { page: 1, itemsPerPage: 25 };
export type PropsErrors<PropsType> = Partial<Record<keyof PropsType, string>>;
export abstract class ListPage<
ItemType,
PropsType extends BaseListPageProps = BaseListPageProps
> extends Page {
abstract getItems(ctx: BaseContext, props: PropsType): Promise<{ items: ItemType[] }>;
abstract getTotalPages(ctx: BaseContext, props: PropsType): Promise<number>;
abstract renderItem(ctx: BaseContext, item: ItemType): Promise<Templatable>;
abstract propsParser: PagePropsParser<PropsType>;
filterFields: FormField<keyof PropsType>[] = [];
filterControls: FormFieldControl[] = [];
renderListContainer(_: BaseContext, content: Templatable): Templatable {
return tempstream`<div>${content}</div>`;
}
async validateProps(
ctx: BaseContext,
props: PropsType
): Promise<{ valid: boolean; errors: PropsErrors<PropsType> }> {
const errors: PropsErrors<PropsType> = {};
let has_errors = false;
const promises = [];
for (const [key, value] of Object.entries(props)) {
const field = FormFieldsList.getField(this.filterFields, key);
if (field) {
promises.push(
field._validate(ctx, value).then(({ valid, message }) => {
if (!valid) {
has_errors = true;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
errors[key as keyof PropsType] = message;
}
})
);
}
}
await Promise.all(promises);
return { valid: has_errors, errors };
}
async getProps(ctx: BaseContext): Promise<{
parsed_props: PropsType;
errors: PropsErrors<PropsType>;
raw_props: PropsType;
}> {
const raw_props = this.propsParser.decode(ctx);
const parsed_props = { ...raw_props };
const { errors } = await this.validateProps(ctx, parsed_props);
for (const prop_name in errors) {
const default_value = this.propsParser.getDefaultValue(prop_name);
if (default_value !== undefined) {
parsed_props[prop_name] = default_value;
} else {
delete parsed_props[prop_name];
}
}
return { parsed_props, errors, raw_props };
}
async render(ctx: BaseContext) {
const { parsed_props, errors, raw_props } = await this.getProps(ctx);
return tempstream`${this.renderPagination(ctx, parsed_props)}
${this.renderFilters(ctx, parsed_props, raw_props, errors)}
${this.getItems(ctx, parsed_props).then(({ items }) =>
this.renderListContainer(
ctx,
items.map((item) => this.renderItem(ctx, item))
)
)}`;
}
async renderPagination(ctx: BaseContext, props: PropsType) {
const totalIems = await this.getTotalPages(ctx, props);
const currentPage = props.page;
return tempstream/* HTML */ `<center>
${currentPage > 1 ? this.renderPageButton(ctx, 1, "Pierwsza strona") : ""}
${currentPage > 1
? this.renderPageButton(ctx, currentPage - 1, "Poprzednia strona")
: ""}
<select onchange="if (this.value) Turbo.visit(this.value)">
${Array.from(naturalNumbers(1, await this.getTotalPages(ctx, props))).map(
(n) => /* HTML */ `<option
value="${UrlWithNewParams(
ctx,
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
this.propsParser.overwriteProp(ctx, {
page: n,
} as Partial<PropsType>)
)}"
${currentPage === n ? "selected" : ""}
>
${n}
</option>`
)}
</select>
${currentPage < totalIems
? this.renderPageButton(ctx, currentPage + 1, "Następna strona")
: ""}
${currentPage < totalIems
? this.renderPageButton(ctx, totalIems, "Ostatnia strona")
: ""}
</center>`;
}
private renderPageButton(ctx: BaseContext, page: number, text: string) {
return /* HTML */ `<a
href="${UrlWithNewParams(
ctx,
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
this.propsParser.overwriteProp(ctx, {
page,
} as Partial<PropsType>)
)}"
>${text}</a
>`;
}
renderFilters(
ctx: BaseContext,
parsed_props: PropsType, // parsed props don't include wrong values
raw_props: PropsType,
errors: PropsErrors<PropsType>
) {
return tempstream/* HTML */ `<form method="GET">
${this.propsParser.makeHiddenInputs(parsed_props, [
"page",
...this.filterFields.map((f) => f.name),
])}
${this.filterControls.map((control) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
control.render(ctx, this.filterFields, {
values: parsed_props,
raw_values: raw_props,
errors,
messages: [],
} as FormData)
)}
</form>`;
}
}
export abstract class SealiousItemListPage<
C extends Collection,
PageProps extends BaseListPageProps = BaseListPageProps
> extends ListPage<CollectionItem<C>, PageProps> {
constructor(public collection: C) {
super();
}
async getTotalPages(ctx: BaseContext, props: PageProps) {
const { items } = await this.collection.list(ctx.$context).fetch();
return Math.ceil(items.length / props.itemsPerPage);
}
async getItems(ctx: BaseContext, props: PageProps) {
return {
items: (
await this.collection
.list(ctx.$context)
.paginate({ items: props.itemsPerPage, page: props.page })
.fetch()
).items,
};
}
async renderItem(_: BaseContext, item: CollectionItem<C>): Promise<Templatable> {
return `<div>${item.id}</div>`;
}
canAccess = peopleWhoCan("list", this.collection);
}

@ -0,0 +1,31 @@
import Router from "@koa/router";
import { BaseContext } from "koa";
import { Templatable, tempstream } from "tempstream";
export type PageErrorMessage = { type: "access" | "internal"; message: string };
export interface Mountable {
mount: (router: Router, path: string) => void;
canAccess: (ctx: BaseContext) => Promise<{ canAccess: boolean; message: string }>;
renderError(ctx: BaseContext, error: PageErrorMessage): Promise<Templatable>;
}
export abstract class Page implements Mountable {
mount(router: Router, path: string) {
router.get(path, async (ctx) => {
ctx.body = await this.render(ctx);
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: BaseContext) {
return { canAccess: true, message: "" };
}
async renderError(_: BaseContext, error: PageErrorMessage) {
return tempstream/* HTML */ `<div>${error.message}</div>`;
}
public abstract render(ctx: BaseContext): Promise<Templatable>;
}

@ -0,0 +1,81 @@
import { hasShape, predicates, Shape, ShapeToType } from "@sealcode/ts-predicates";
import { BaseContext } from "koa";
import merge from "merge";
import { BasePageProps } from "./list";
export type EncodedProps = Record<string, unknown>;
// the intention here is to sometime in the future be able to store multiple frames on one document, so props for each frame will be in a different namespace, and parsers are going to help with that
function parseStringValues<TheShape extends Shape>(
shape: TheShape,
values: Record<string, string>
): ShapeToType<TheShape> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values)) {
if (!(key in shape)) {
continue;
}
const predicate = shape[key];
if (predicate == predicates.number) {
result[key] = parseFloat(value);
} else {
result[key] = value;
}
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return result as ShapeToType<TheShape>;
}
export abstract class PagePropsParser<PropsType extends BasePageProps> {
abstract decode(ctx: BaseContext): PropsType;
abstract encode(props: PropsType): EncodedProps;
abstract getHTMLInputName(prop_name: string): string;
constructor(public propsShape: Shape, public defaultValues: Partial<PropsType>) {}
overwriteProp(ctx: BaseContext, new_props: Partial<PropsType>): EncodedProps {
const result = {};
merge.recursive(result, this.decode(ctx), new_props);
return result;
}
makeHiddenInputs(values: PropsType, fields_to_skip: string[]): string {
return Object.entries(values)
.filter(([key]) => !fields_to_skip.includes(key))
.map(
([key, value]: [string, string | number]) =>
/* HTML */ `<input type="hidden" name="${key}" value="${value}" />`
)
.join(" ");
}
getDefaultValue<Key extends keyof PropsType>(key: Key): PropsType[Key] | undefined {
return this.defaultValues[key];
}
}
export class AllQueryParams<
PropsType extends BasePageProps
> extends PagePropsParser<PropsType> {
decode(ctx: BaseContext): PropsType {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const query = parseStringValues(this.propsShape, {
...this.defaultValues,
...ctx.query,
} as unknown as Record<string, string>);
if (!hasShape(this.propsShape, query)) {
throw new Error("Wrong props shape");
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return query as PropsType;
}
encode(props: PropsType): Record<string, unknown> {
return props;
}
getHTMLInputName(prop_name: string): string {
return prop_name;
}
}

@ -1,3 +0,0 @@
describe("policy-types", () => {
require("./roles.subtest");
});

@ -1,58 +0,0 @@
import assert from "assert";
import { TestUtils } from "sealious";
import { withProdApp } from "../test_utils/with-prod-app";
const ALLOWED_ROLES = ["admin"];
describe("roles", () => {
it("allows access to users with designated role and denies access to users without it", async () =>
withProdApp(async ({ app, rest_api }) => {
await app.collections.users.suCreate({
username: "regular-user",
password: "password",
email: "regular@example.com",
roles: [],
});
const admin = await app.collections.users.suCreate({
username: "admin",
password: "admin-password",
email: "admin@example.com",
roles: [],
});
await app.collections["user-roles"].suCreate({
user: admin.id,
role: "admin",
});
await app.collections.secrets.suCreate({
content: "It's a secret to everybody",
});
const admin_session = await rest_api.login({
username: "admin",
password: "admin-password",
});
const { items: admin_response } = await rest_api.get(
"/api/v1/collections/secrets",
admin_session
);
assert.equal(admin_response.length, 1);
const user_session = await rest_api.login({
username: "regular-user",
password: "password",
});
await TestUtils.assertThrowsAsync(
() => rest_api.get("/api/v1/collections/secrets", user_session),
(error) => {
assert.equal(
error.response.data.message,
app.i18n("policy_roles_deny", [ALLOWED_ROLES.join(", ")])
);
}
);
}));
});

@ -0,0 +1,24 @@
import { withProdApp } from "../test_utils/with-prod-app";
describe("roles", () => {
it("allows access to users with designated role and denies access to users without it", async () =>
withProdApp(async ({ app }) => {
await app.collections.users.suCreate({
username: "regular-user",
password: "password",
email: "regular@example.com",
roles: [],
});
const admin = await app.collections.users.suCreate({
username: "someadmin",
password: "admin-password",
email: "admin@example.com",
roles: [],
});
await app.collections["user-roles"].suCreate({
user: admin.id,
role: "admin",
});
}));
});

@ -1,25 +0,0 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import finalizePasswordReset from "./finalize-password-reset";
import confirmPasswordReset from "./confirm-password-reset";
import finalizeRegistrationIntent from "./finalize-registration-intent";
import createRouter from "./create/create.routes";
import { confirmRegistrationRouter } from "./confirm-registration-email/confirm-registration-email.routes";
export const accountsRouter = (router: Router): void => {
router.post(
"/account/finalize-registration-intent",
Middlewares.parseBody,
finalizeRegistrationIntent
);
router.post(
"/account/finalize-password-reset",
Middlewares.parseBody,
finalizePasswordReset
);
router.get("/account/confirm-password-reset", confirmPasswordReset);
createRouter(router);
confirmRegistrationRouter(router);
};

@ -1,6 +0,0 @@
describe("routes", () => {
// require("./finalize-registration-intent.subtest");
// require("./finalize-password-reset.subtest");
require("./confirm-password-reset.subtest");
require("./account-creation-details.subtest");
});

@ -1,11 +0,0 @@
import axios from "axios";
import { withProdApp } from "../../test_utils/with-prod-app";
describe("confirm-password-reset", () => {
it("displays an html form", async () =>
withProdApp(async ({ base_url }) => {
await axios.get(
`${base_url}/confirm-password-reset?token=kupcia&email=dupcia`
);
}));
});

@ -1,80 +0,0 @@
import { Middleware } from "@koa/router";
import * as assert from "assert";
import { App } from "sealious";
const render_form = async (app: App, token: string, email: string) => /* HTML */ `
<!DOCTYPE html>
<html>
<style>
html {
background-color: #edeaea;
}
body {
max-width: 21cm;
margin: 1cm auto;
font-family: sans-serif;
background-color: white;
padding: 1cm;
box-sizing: border-box;
}
.reveal-button {
margin-left: -0.5rem;
}
</style>
<meta charset="utf-8" />
<title>${app.i18n("password_reset_cta")}</title>
<img src="/api/v1/logo" alt="${app.manifest.name} - logo" />
<h1>${app.i18n("password_reset_cta")}</h1>
<form method="POST" action="/finalize-password-reset">
<input type="hidden" name="token" value="${token}" />
<input type="hidden" name="email" value="${email}" />
<fieldset>
<legend>${app.i18n("password_reset_input_cta", [email])}</legend>
<input id="pwd" name="password" type="password" size="32" />
<button
id="reveal"
class="reveal-button"
onclick="toggle(event)"
title="${app.i18n("reveal_password")}"
>
🙈
</button>
<br />
<input type="submit" value="${app.i18n("password_reset_cta")}" />
</fieldset>
</form>
<script>
function toggle(event) {
event.preventDefault();
if (pwd.type == "password") {
pwd.type = "text";
reveal.textContent = "👀";
} else {
pwd.type = "password";
reveal.textContent = "🙈";
}
return null;
}
</script>
</html>
`;
const confirmPasswordReset: Middleware = async (ctx) => {
assert.ok(ctx.request.query.token);
assert.ok(ctx.request.query.email);
if (typeof ctx.request.query.token !== "string") {
throw new Error("Token isn't a string or is missing");
}
if (typeof ctx.request.query.email !== "string") {
throw new Error("Email isn't a string or is missing");
}
ctx.body = await render_form(
ctx.$app,
ctx.request.query.token,
ctx.request.query.email
);
};
export default confirmPasswordReset;

@ -1,68 +0,0 @@
import Router from "@koa/router";
import { Errors, Middlewares } from "sealious";
import { formHasAllFields, formHasSomeFields } from "../../common/form";
import { accountCreationDetailsForm } from "./confirm-registration-email.views";
export const confirmRegistrationRouter = (router: Router): void => {
router.get(
"/account/confirm-registration-email",
Middlewares.extractContext(),
async (ctx) => {
if (!formHasAllFields(ctx, <const>["email", "token"], ctx.query)) return;
ctx.body = await accountCreationDetailsForm(ctx, { values: ctx.query });
}
);
router.post(
"/account/confirm-registration-email",
Middlewares.extractContext(),
Middlewares.parseBody(),
async (ctx) => {
if (
!formHasSomeFields(ctx, <const>["username", "password"], ctx.$body) ||
!formHasAllFields(ctx, <const>["token", "email"], ctx.$body)
)
return;
try {
const { items: matching_intents } = await ctx.$app.collections[
"registration-intents"
]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (matching_intents.length !== 1) {
ctx.status = 403;
return;
}
await ctx.$app.collections.users.suCreateUnsafe({
username: ctx.$body.username,
email: matching_intents[0].get("email"),
password: ctx.$body.password,
});
await (
await ctx.$app.collections["registration-intents"].getByID(
new ctx.$app.SuperContext(),
matching_intents[0].id
)
).delete(new ctx.$app.SuperContext());
ctx.status = 303;
ctx.redirect("account-created");
} catch (e) {
console.log("error", e);
if (Errors.FieldsError.isFieldsError(ctx.$app.collections.users, e)) {
ctx.status = 422;
ctx.body = await accountCreationDetailsForm(ctx, {
values: {
username: ctx.$body.username,
email: ctx.$body.email,
token: ctx.$body.token,
},
errors: e.getSimpleMessages(),
});
return;
}
}
}
);
};

@ -1,24 +0,0 @@
import axios from "axios";
import assert from "assert";
import { TestUtils } from "sealious";
import { withProdApp } from "../../../test_utils/with-prod-app";
describe("account-creation-details", () => {
it("throws when no token/email is present", () =>
withProdApp(({ base_url }) =>
TestUtils.assertThrowsAsync(
async () => {
await axios.get(`${base_url}/account-creation-details`);
},
async function () {}
)
));
it("displays an html form after the positive flow", () =>
withProdApp(async ({ base_url }) => {
const resp = await axios.get(
`${base_url}/account-creation-details?token=oieajgoiea&email=ababab@ok.pl`
);
assert.deepEqual(resp.status, 200);
assert(resp.data.length);
}));
});

@ -1,55 +0,0 @@
import { BaseContext } from "koa";
import html from "../../../html";
import navbar from "../../common/navbar";
import input from "../../common/ui/input";
export async function accountCreationDetailsForm(
ctx: BaseContext,
{
values,
errors,
}: {
values: { token: string; email: string; username?: string };
errors?: { email?: string; username?: string; password?: string };
}
) {
errors = errors || {};
return html(
ctx,
/* HTML */ `
${navbar(ctx)}
<h1>${ctx.$app.i18n("registration_intent_cta")}</h1>
<form method="POST" id="form" action="/account/confirm-registration-email">
<input type="hidden" name="token" value="${values.token || ""}" />
<fieldset>
<legend>
${ctx.$app.i18n("registration_intent_form_description")}
</legend>
${input({
name: "email",
type: "email",
value: values.email || "",
readonly: true,
error: "",
})}
${input({
name: "username",
value: values.username,
error: errors.username || "",
type: "text",
})}
${input({
name: "password",
value: "",
error: errors.password || "",
type: "password",
})}
<input
type="submit"
value="${ctx.$app.i18n("registration_intent_cta")}"
/>
</fieldset>
</form>
`
);
}

@ -1,41 +0,0 @@
import Router from "@koa/router";
import { Errors, Middlewares } from "sealious";
import html from "../../../html";
import { formHasSomeFields } from "../../common/form";
import { createAccountForm } from "./create.views";
export default function createRouter(router: Router) {
router.use("/account/create", Middlewares.extractContext());
router.get("/account/create", (ctx) => {
console.log({ ctx });
ctx.body = createAccountForm(ctx);
});
router.get(
"/account/create/email-sent",
(ctx) => (ctx.body = html(ctx, `Registration email sent`))
);
router.post("/account/create", Middlewares.parseBody(), async (ctx) => {
const registrationIntents = ctx.$app.collections["registration-intents"];
// the line below enables typescript to deduce the type of ctx.$body and
// avoid type assertions
if (!formHasSomeFields(ctx, <const>["email"], ctx.$body)) return;
try {
await registrationIntents.create(ctx.$context, ctx.$body);
ctx.status = 303;
ctx.redirect("/account/create/email-sent");
} catch (e) {
if (Errors.FieldsError.isFieldsError(registrationIntents, e)) {
ctx.status = 422;
ctx.body = createAccountForm(ctx, {
values: { email: ctx.$body.email },
errors: e,
});
} else {
ctx.body = "error";
}
}
});
}

@ -1,32 +0,0 @@
import { BaseContext } from "koa";
import { Errors } from "sealious";
import RegistrationIntents from "../../../collections/registration-intents";
import html from "../../../html";
import { CollectionTiedFormData } from "../../common/form";
import navbar from "../../common/navbar";
import input from "../../common/ui/input";
export function createAccountForm(
ctx: BaseContext,
{ values, errors }: CollectionTiedFormData<RegistrationIntents> = {
values: {},
}
) {
errors =
errors ||
new Errors.FieldsError(ctx.$app.collections["registration-intents"], {}); // empty error;
return html(
ctx,
/* HTML */ `<title>Sign up</title>${navbar(ctx)}
<h1>Register</h1>
<form action="/account/create" method="POST">
${input({
name: "email",
value: values.email,
type: "email",
error: errors.getErrorForField("email"),
})}
<input type="submit" value="register" />
</form>`
);
}

@ -1,65 +0,0 @@
import assert from "assert";
import { AxiosError } from "axios";
import { TestUtils } from "sealious";
import TheApp from "../../app";
import { withProdApp } from "../../test_utils/with-prod-app";
describe.only("finalize password reset", () => {
async function createAUser(app: TheApp) {
await app.collections.users.suCreate({
username: "user",
email: "user@example.com",
password: "password",
roles: [],
});
}
it("allows to change a password (entire flow)", async () =>
withProdApp(async ({ app, mail_api, rest_api }) => {
await createAUser(app);
const options = await rest_api.login({
username: "user",
password: "password",
});
await rest_api.delete("/api/v1/collections/sessions/current", options);
await rest_api.post("/api/v1/collections/password-reset-intents", {
email: "user@example.com",
});
const message_metadata = (await mail_api.getMessages()).filter(
(message) => message.recipients[0] == "<user@example.com>"
)[0];
assert(message_metadata.subject);
const message = await mail_api.getMessageById(message_metadata.id);
const matches = /token=([^?&]+)/.exec(message);
if (!matches) {
throw new Error("token not found in the message");
}
const token = matches[1];
await rest_api.post("/finalize-password-reset", {
email: "user@example.com",
token,
password: "new-password",
});
await rest_api.post(
"/api/v1/sessions",
{ username: "user", password: "new-password" },
options
);
await TestUtils.assertThrowsAsync(
async () =>
rest_api.post("/finalize-password-reset", {
email: "user@example.com",
token,
password: "using the same token twice hehehehhee",
}),
(e: AxiosError) => {
assert.strictEqual(e?.response?.data?.message, "Incorrect token");
}
);
}));
});

@ -1,52 +0,0 @@
import { Middleware } from "@koa/router";
import { URL } from "url";
import { Errors } from "sealious";
import { hasShape, predicates } from "@sealcode/ts-predicates";
const finalizePasswordReset: Middleware = async (ctx) => {
if (
!hasShape(
{
redirect: predicates.or(predicates.string, predicates.undefined),
token: predicates.string,
password: predicates.string,
},
ctx.$body
)
) {
throw new Error("Wrong parameters. Needed: token, password. Optional: redirect.");
}
const intent_response = await ctx.$app.collections["password-reset-intents"]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (intent_response.empty) {
throw new Errors.BadContext("Incorrect token");
}
const intent = intent_response.items[0];
const user_response = await ctx.$app.collections.users
.suList()
.filter({ email: intent.get("email") as string })
.fetch();
if (user_response.empty) {
throw new Error("No user with this email address.");
}
user_response.items[0].set("password", ctx.$body.password);
await user_response.items[0].save(new ctx.$app.SuperContext());
await intent.remove(new ctx.$app.SuperContext());
if (
ctx.$body.redirect &&
new URL(ctx.$app.manifest.base_url).origin == new URL(ctx.$body.redirect).origin
) {
ctx.redirect(ctx.$body.redirect);
} else {
ctx.body = "Password reset successful";
}
};
export default finalizePasswordReset;

@ -1,43 +0,0 @@
import * as assert from "assert";
import { withProdApp } from "../../test_utils/with-prod-app";
describe("finalize registration", () => {
it("allows to register an account (entire flow)", async () =>
withProdApp(async ({ app, mail_api, rest_api }) => {
app.ConfigManager.set("roles", ["admin"]);
await rest_api.post("/api/v1/collections/registration-intents", {
email: "user@example.com",
role: "admin",
});
const message_metadata = (await mail_api.getMessages()).filter(
(message) => message.recipients[0] == "<user@example.com>"
)[0];
assert.ok(message_metadata?.subject);
const message = await mail_api.getMessageById(message_metadata.id);
const match_result = /token=([^?&]+)/.exec(message);
if (!match_result) {
throw new Error("Didn't find a token");
}
const token = match_result[1];
await rest_api.post("/finalize-registration-intent", {
email: "user@example.com",
token,
password: "password",
username: "user",
});
const options = await rest_api.login({
username: "user",
password: "password",
});
const response = await rest_api.get(
"/api/v1/collections/users/me?attachments[roles]=true",
options
);
assert.equal(response.items[0].roles.length, 1);
assert.equal(response.attachments[response.items[0].roles[0]].role, "admin");
}));
});

@ -1,53 +0,0 @@
import { Middleware } from "@koa/router";
import { hasShape, predicates } from "@sealcode/ts-predicates";
import assert from "assert";
const finalizeRegistrationIntent: Middleware = async (ctx) => {
if (
!hasShape(
{
token: predicates.string,
username: predicates.string,
password: predicates.string,
},
ctx.$body
)
) {
throw new Error("Missing attributes. Required: token, username, password");
}
const intents = await ctx.$app.collections["registration-intents"]
.suList()
.filter({ token: ctx.$body.token })
.fetch();
if (intents.empty) {
throw new Error("Incorrect token");
}
const intent = intents.items[0];
const user = await ctx.$app.collections.users.suCreate({
password: ctx.$body.password,
username: ctx.$body.username,
email: intent.get("email") as string,
roles: [],
});
if (intent.get("role")) {
await ctx.$app.collections["user-roles"].suCreate({
user: user.id,
role: intent.get("role") as string,
});
}
await intent.remove(new ctx.$app.SuperContext());
const target_path = ctx.$app.ConfigManager.get("accout_creation_success_path");
if (target_path) {
assert.strictEqual(
target_path[0],
"/",
"'accout_creation_success_path' set, but doesn't start with a '/'"
);
ctx.body = `<meta http-equiv="refresh" content="0; url=${target_path}" />`;
}
ctx.body = "Account creation successful";
ctx.status = 201;
};
export default finalizeRegistrationIntent;

@ -8,6 +8,11 @@ export interface CollectionTiedFormData<C extends Collection> {
errors?: Errors.FieldsError<C>;
}
export interface FormFields<Fields extends string> {
values: Partial<{ [field in Fields]: string }>;
errors?: Partial<{ [field in Fields]: string }>;
}
export function formHasAllFields<Fields extends readonly string[]>(
ctx: BaseContext,
fields: Fields,

@ -2,18 +2,15 @@ import html from "../../html";
import { BaseContext } from "koa";
import { Readable } from "stream";
import { tempstream } from "tempstream";
import navbar from "./navbar";
import { NewTask, TaskList } from "../tasks/tasks.views";
export function MainView(ctx: BaseContext): Readable {
return html(
ctx,
tempstream/* HTML */ ` <title>My Own ToDo App</title>
<body>
${navbar(ctx)}
<h1>My ToDo App (with esbuild!)</h1>
"",
tempstream/* HTML */ `
<title>My Own ToDo App</title>
${TaskList(ctx.$context)} ${NewTask()}
</body>`
<h1>Sealious App</h1>
`
);
}

@ -1,18 +1,18 @@
import { BaseContext } from "koa";
export default function navbar(ctx: BaseContext) {
export default async function navbar(ctx: BaseContext) {
return /* HTML */ ` <nav>
<a href="/" style="display: flex; align-items: center">
<a href="/" class="nav-logo">
<img
src="/assets/logo"
alt="${ctx.$app.manifest.name} - logo"
width="50"
height="50"
/>
Sealious Playground
Sealious App
</a>
<ul>
<li><a href="/account/create">Register</a></li>
<li><a href="/logowanie">Logowanie</a></li>
</ul>
</nav>`;
}

@ -7,6 +7,7 @@ export default function input({
placeholder,
error,
readonly,
required,
}: {
name: string;
id?: string;
@ -16,6 +17,7 @@ export default function input({
placeholder?: string;
readonly?: boolean;
error: string;
required?: boolean;
}) {
id = id || name;
label = label || name;
@ -23,6 +25,7 @@ export default function input({
value = value || "";
placeholder = placeholder || type;
readonly = readonly || false;
required = required || false;
return /* HTML */ `<div class="input">
<label for="${id}">${label}</label>
<input
@ -32,6 +35,7 @@ export default function input({
value="${value}"
placeholder="${placeholder}"
${readonly ? "readonly" : ""}
${required ? "required" : ""}
/>
${error ? `<div class="input__error">${error}</div>` : ""}
</div>`;

@ -0,0 +1,10 @@
import { webhintURL } from "../test_utils/webhint";
import { withProdApp } from "../test_utils/with-prod-app";
describe("homepage", function () {
this.timeout(200000);
it("passes webhint tests", () =>
withProdApp(async ({ base_url }) => {
await webhintURL(`${base_url}/`);
}));
});

@ -1,16 +1,16 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import { accountsRouter } from "./account/account.routes";
import { MainView } from "./common/main-view";
import { loginRouter } from "./login/login.routes";
import { tasksRouter } from "./tasks/tasks.routes";
import mountAutoRoutes from "./routes";
export const mainRouter = (router: Router): void => {
router.get("/", Middlewares.extractContext(), async (ctx) => {
ctx.body = MainView(ctx);
});
router.use(Middlewares.extractContext());
loginRouter(router);
tasksRouter(router);
accountsRouter(router);
mountAutoRoutes(router);
};

@ -1,10 +1,14 @@
import Router from "@koa/router";
import { hasFieldOfType, hasShape, predicates } from "@sealcode/ts-predicates";
import { Middlewares } from "sealious";
import html from "../../html";
import { LoginForm } from "./login.views";
import { formHasSomeFields } from "../common/form";
import { MyProfileURL } from "../routes";
export const loginRouter = (router: Router): void => {
router.get("/login", Middlewares.extractContext(), async (ctx) => {
ctx.body = html(ctx, LoginForm());
router.get("/logowanie", Middlewares.extractContext(), async (ctx) => {
ctx.body = html(ctx, "Logowanie", LoginForm());
});
router.post(
@ -12,61 +16,41 @@ export const loginRouter = (router: Router): void => {
Middlewares.extractContext(),
Middlewares.parseBody(),
async (ctx) => {
if (!formHasSomeFields(ctx, <const>["username", "password"], ctx.$body))
return;
if (
!hasShape(
{ username: predicates.string, password: predicates.string },
ctx.$body
)
) {
ctx.body = "brakuje hasła lub loginu";
return;
}
try {
const session_id = await ctx.$app.collections.sessions.login(
ctx.$body.username as string,
ctx.$body.password as string
ctx.$body.username,
ctx.$body.password
);
ctx.cookies.set("sealious-session", session_id, {
maxAge: 1000 * 60 * 60 * 24 * 7,
secure: ctx.request.protocol === "https",
overwrite: true,
});
ctx.redirect("/user");
ctx.redirect(MyProfileURL);
} catch (e) {
ctx.status = 422;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (!hasFieldOfType(e, "message", predicates.string)) {
console.error(e);
return;
}
ctx.body = html(
ctx,
LoginForm(ctx.$body.username as string, (e as Error).message)
"Logowanie",
LoginForm(ctx.$body.username, e.message)
);
}
}
);
};
function LoginForm(username = "", error_message?: string): string {
if (error_message) {
error_message =
error_message == "Incorrect username!"
? "Niepoprawna nazwa użytkownika!"
: "Niepoprawne hasło!";
}
return /* HTML */ `
<turbo-frame id="login">
<h2>Zaloguj</h2>
<form method="POST" action="/login" data-turbo-frame="_top">
${error_message ? `<div>${error_message}</div>` : ""}
<label for="username">
Nazwa użytkownika:
<input
id="username"
name="username"
type="text"
value="${username}"
required
/>
</label>
<label for="password"
>Hasło:
<input
id="password"
name="password"
type="password"
value=""
required
/></label>
<input type="submit" value="Zaloguj" />
</form>
</turbo-frame>
`;
}

@ -0,0 +1,39 @@
import { withProdApp } from "../../test_utils/with-prod-app";
describe("login", () => {
it("displays login form", async () => {
return withProdApp(async ({ rest_api }) => {
const result = await rest_api.get("/logowanie");
const usernameStructure = `
<label for="username">Nazwa użytkownika:</label>
<input
id="username"
type="text"
name="username"
value=""
placeholder="text"
required />`;
const passwordStructure = `
<label for="password">Hasło:</label>
<input
id="password"
type="password"
name="password"
value=""
placeholder="password"
required />`;
if (
!(
result
.replace(/\s/g, "")
.includes(usernameStructure.replace(/\s/g, "")) &&
result
.replace(/\s/g, "")
.includes(passwordStructure.replace(/\s/g, ""))
)
)
throw new Error("Bad html structure!");
});
});
});

@ -0,0 +1,37 @@
import input from "../common/ui/input";
export function LoginForm(username = "", error_message = ""): string {
let error_username = "";
let error_password = "";
if (error_message === "Incorrect username!") error_username = error_message;
else error_password = error_message;
return /* HTML */ `
<turbo-frame id="login">
<h2>Zaloguj</h2>
<form method="POST" action="/login" data-turbo-frame="_top">
<label for="username">
${input({
name: "username",
id: "username",
value: username,
type: "text",
required: true,
label: "Nazwa użytkownika:",
error: error_username,
})}
</label>
<label for="password">
${input({
id: "password",
name: "password",
type: "password",
required: true,
label: "Hasło:",
error: error_password,
})}
</label>
<input type="submit" value="Zaloguj" />
</form>
</turbo-frame>
`;
}

@ -0,0 +1,18 @@
// DO NOT EDIT! This file is generated automaticaly with npm run generate-routes
import Router from "@koa/router";
import { Middlewares } from "sealious";
import { default as TestComplex } from "./test-complex.form";
import { default as MyProfile } from "./users/me.page";
export const TestComplexURL = "/test-complex/";
export const MyProfileURL = "/users/me/";
export default function mountAutoRoutes(router: Router) {
router.use(TestComplexURL, Middlewares.extractContext(), Middlewares.parseBody());
TestComplex.mount(router, TestComplexURL);
router.use(MyProfileURL, Middlewares.extractContext(), Middlewares.parseBody());
MyProfile.mount(router, MyProfileURL);
}

@ -1,29 +0,0 @@
import Router from "@koa/router";
import { Middlewares } from "sealious";
import { MainView } from "../common/main-view";
export const tasksRouter = (router: Router): void => {
router.post(
"/tasks",
Middlewares.extractContext(),
Middlewares.parseBody(),
async (ctx) => {
await ctx.$app.collections.tasks
.make({
title: ctx.$body.title as string,
done: false,
})
.save(ctx.$context);
ctx.body = MainView(ctx);
}
);
router.delete("/tasks/:task_id", Middlewares.extractContext(), async (ctx) => {
const task = await ctx.$app.collections.tasks.getByID(
ctx.$context,
ctx.params.task_id
);
await task.remove(ctx.$context);
ctx.body = MainView(ctx);
});
};

@ -1,49 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { CollectionItem, Context } from "sealious";
import frame from "../../frame";
export function Task(task: CollectionItem<never>): string {
return frame(
`task-${task.id}`,
/* HTML */ `<li class="task">
<input
type="checkbox"
data-controller="task"
data-action="task#toggle"
data-id="${task.id}"
${task.get("done") ? "checked" : ""}
/>
${task.get("title")}
<form method="DELETE" action="/tasks/${task.id}" data-turbo-frame="task-list">
<input class="delete-button" type="submit" value="🗑" />
</form>
</li>`
);
}
export async function TaskList(context: Context): Promise<string> {
const { items: tasks } = await context.app.collections.tasks.list(context).fetch();
return frame(
"task-list",
/* HTML */ `
<ul>
${tasks.map(Task).join("\n")}
</ul>
`
);
}
export function NewTask(): string {
return frame(
"new-task",
/* HTML */ `<form method="POST" action="/tasks" data-turbo-frame="task-list">
<input
id="new-task-title"
type="text"
placeholder="write an app"
name="title"
/>
<input type="submit" value="Add" />
</form>`
);
}

@ -0,0 +1,57 @@
import { BaseContext } from "koa";
import { tempstream } from "tempstream";
import { FormFieldControl, SimpleInput } from "../forms/controls";
import { FormField } from "../forms/field";
import Form, { FormData } from "../forms/form";
import html from "../html";
export const actionName = "TestComplex";
class NumberSum<Field1 extends string, Field2 extends string> extends FormFieldControl {
constructor(public field1: Field1, public field2: Field2) {
super([field1, field2]);
}
_render(_: BaseContext, __: FormField[], data: FormData<Field1 | Field2>) {
return tempstream/*HTML */ `<div>Suma liczb ${this.field1} i ${
this.field2
} to: <strong>${
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
parseInt(data.values[this.field1] as string) +
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
parseInt(data.values[this.field2] as string)
}</strong></div>`;
}
}
export default new (class TestComplexForm extends Form {
defaultSuccessMessage = "Pomyślnie utworzono użytkownika";
fields = [
new FormField("A", true),
new FormField("B", true),
new FormField("C", true),
];
controls = [
new SimpleInput("A", { label: "A", type: "number" }),
new SimpleInput("B", { label: "B", type: "number" }),
new SimpleInput("C", { label: "B", type: "number" }),
new NumberSum("A", "B"),
new NumberSum("B", "C"),
];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: BaseContext) {
return { canAccess: true, message: "" };
}
async onSubmit() {
//noop
return;
}
async render(ctx: BaseContext, data: FormData, path: string) {
return html(ctx, "TestComplex", await super.render(ctx, data, path));
}
})();

@ -0,0 +1,17 @@
import { withProdApp } from "../test_utils/with-prod-app";
import { LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
import { TestComplexURL } from "./routes";
describe("TestComplex", () => {
it("doesn't crash", async function () {
this.timeout(LONG_TEST_TIMEOUT);
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(TestComplexURL);
await webhintURL(base_url + TestComplexURL);
// alternatively you can use webhintHTML for faster but less precise scans
// or for scanning responses of requests that use some form of authorization:
// const response = await rest_api.get(TestComplexURL);
// await webhintHTML(response);
});
});
});

@ -0,0 +1,25 @@
import { BaseContext } from "koa";
import { tempstream } from "tempstream";
import html from "../../html";
import { Page } from "../../page/page";
export const actionName = "MyProfile";
export default new (class MyProfilePage extends Page {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async canAccess(_: BaseContext) {
return { canAccess: true, message: "" };
}
async render(ctx: BaseContext) {
const user = await ctx.$context.getUserData(ctx.$app);
if (!user) {
return "User not found";
}
return html(
ctx,
"Mój profil",
tempstream`<div>Welcome, ${user.get("username")}!</div>`
);
}
})();

@ -0,0 +1,17 @@
import { withProdApp } from "../../test_utils/with-prod-app";
import { LONG_TEST_TIMEOUT, webhintURL } from "../../test_utils/webhint";
import { MyProfileURL } from "../routes";
describe("MyProfile", () => {
it("doesn't crash", async function () {
this.timeout(LONG_TEST_TIMEOUT);
return withProdApp(async ({ base_url, rest_api }) => {
await rest_api.get(MyProfileURL);
await webhintURL(base_url + MyProfileURL);
// alternatively you can use webhintHTML for faster but less precise scans
// or for scanning responses of requests that use some form of authorization:
// const response = await rest_api.get(MyProfileURL);
// await webhintHTML(response);
});
});
});

@ -0,0 +1,32 @@
import { Users } from "../collections/collections";
import { CollectionItem, TestUtils } from "sealious";
import TheApp from "../app";
type Unpromisify<T> = T extends Promise<infer R> ? R : T;
export function createAUser(app: TheApp, username: string) {
return app.collections.users.suCreate({
username,
email: `${username}@example.com`,
password: "password",
roles: [],
});
}
export async function createAdmin(
app: TheApp,
rest_api: TestUtils.MockRestApi
): Promise<
[CollectionItem<typeof Users>, Unpromisify<ReturnType<typeof rest_api.login>>]
> {
const user = await createAUser(app, "super_user");
await app.collections["user-roles"].suCreate({
user: user.id,
role: "admin",
});
const session = await rest_api.login({
username: "super_user",
password: "password",
});
return [user, session];
}

@ -0,0 +1,38 @@
import _locreq from "locreq";
const locreq = _locreq(__dirname);
import { spawn } from "child_process";
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
import { promises as fs } from "fs";
export const LONG_TEST_TIMEOUT = 30 * 1000;
export async function webhintURL(url: string, config = locreq.resolve(".hintrc")) {
// eslint-disable-next-line no-console
console.log("scanning with webhint....", url);
try {
const subprocess = spawn(
"node",
[locreq.resolve("node_modules/.bin/hint"), "--config", config, url],
{
stdio: "inherit",
shell: true,
}
);
await new Promise<void>((resolve, reject) => {
subprocess.on("close", (code) =>
code === 0 ? resolve() : reject(new Error("Webhint tests failed"))
);
});
} catch (e) {
if (is(e, predicates.object) && hasShape({ stdout: predicates.string }, e)) {
throw new Error(e.stdout);
} else {
throw e;
}
}
}
export async function webhintHTML(html: string) {
await fs.writeFile("/tmp/index.html", html);
await webhintURL("/tmp/index.html", locreq.resolve(".hintrc.local.json"));
}

@ -29,7 +29,7 @@ export async function withProdApp(
app.config.datastore_mongo = {
host: "db",
port: 27017,
db_name: "sealious-playground-test",
db_name: "sealious-app-test",
};
app.config.logger.level = <const>"none";
app.mailer = new SMTPMailer({

@ -8,12 +8,13 @@
"target": "ES2019",
"esModuleInterop": true,
"lib": ["es6", "esnext"],
"outDir": "../../dist",
"outDir": "../../dist/back",
"keyofStringsOnly": true,
"checkJs": true,
"allowJs": true,
"resolveJsonModule": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["./**/*"]
"include": ["./**/*", "./*"]
}

@ -0,0 +1,22 @@
import { BaseContext } from "koa";
import qs from "qs";
export async function sleep(time: number) {
return new Promise((resolve) => setTimeout(resolve, time));
}
export type Awaited<T> = T extends Promise<infer U> ? U : T;
export type UnwrapArray<T> = T extends Array<infer U> ? U : T;
export function* naturalNumbers(min: number, max: number) {
for (let i = min; i <= max; i++) {
yield i;
}
}
export function UrlWithNewParams(
ctx: BaseContext,
query_params: Record<string, unknown>
): string {
return `${ctx.path}?${qs.stringify(query_params)}`;
}

@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Controller } from "stimulus";
export default class TaskController extends Controller {
id: string;
connect(): void {
this.id = this.element.attributes["data-id"].value;
}
async toggle(event: Event): Promise<void> {
await fetch(`/api/v1/collections/tasks/${this.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
done: (event.target as HTMLInputElement).checked,
}),
});
}
}

@ -1,8 +1,6 @@
import * as Turbo from "@hotwired/turbo";
import { Application } from "stimulus";
import TaskController from "./controllers/task_controller";
export { Turbo };
const application = Application.start();
application.register("task", TaskController);

@ -9,17 +9,6 @@ body {
padding: 1rem;
}
.task {
list-style: none;
display: flex;
align-items: center;
height: 1.5rem;
}
.task input[type="checkbox"] {
margin-right: 0.5rem;
}
.delete-button {
height: 1rem;
padding: 0;
@ -27,6 +16,12 @@ body {
padding: 0.5rem;
}
.nav-logo {
display: flex;
align-items: center;
}
// === Views ===
@import "back/routes/common/ui/input.scss";
@import "back/forms/forms.scss";

Loading…
Cancel
Save