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)