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/D1253master
parent
9e3667398e
commit
651cd48220
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"phabricator.uri": "https://hub.sealcode.org/",
|
"phabricator.uri": "https://hub.sealcode.org/",
|
||||||
"arc.land.onto.default": "hotwire",
|
"arc.land.onto.default": "master",
|
||||||
"load": ["arcanist-linters", "arc-unit-mocha/src"],
|
"load": ["arcanist-linters", "arc-unit-mocha/src"],
|
||||||
"unit.engine": "MochaEngine",
|
"unit.engine": "MochaEngine",
|
||||||
"unit.mocha.include": ["./lib/**/*.test.js"],
|
"unit.mocha.include": ["./lib/**/*.test.js"],
|
||||||
"unit.mocha.dockerRoot": "/opt/sealious-playground"
|
"unit.mocha.dockerRoot": "/opt/sealious-app"
|
||||||
}
|
}
|
||||||
|
@ -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
|
## Installation
|
||||||
rendered front-end.
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose up -d db
|
./npm.sh install
|
||||||
|
|
||||||
npm install
|
|
||||||
npm run watch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
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
|
||||||
|
```
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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,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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,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"));
|
|
||||||
}));
|
|
||||||
});
|
|
@ -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 { Templatable, tempstream } from "tempstream";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { BaseContext } from "koa";
|
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");
|
ctx.set("content-type", "text/html;charset=utf-8");
|
||||||
return tempstream/* HTML */ ` <!DOCTYPE html>
|
return tempstream/* HTML */ ` <!DOCTYPE html>
|
||||||
<html>
|
<html lang="pl">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width" />
|
${makeHead(ctx, title)}
|
||||||
<script async src="/dist/bundle.js"></script>
|
|
||||||
<link href="/dist/style.css" rel="stylesheet" type="text/css" />
|
|
||||||
</head>
|
</head>
|
||||||
${body}
|
<body>
|
||||||
|
${navbar(ctx)} ${body}
|
||||||
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
@ -1,18 +1,18 @@
|
|||||||
import { BaseContext } from "koa";
|
import { BaseContext } from "koa";
|
||||||
|
|
||||||
export default function navbar(ctx: BaseContext) {
|
export default async function navbar(ctx: BaseContext) {
|
||||||
return /* HTML */ ` <nav>
|
return /* HTML */ ` <nav>
|
||||||
<a href="/" style="display: flex; align-items: center">
|
<a href="/" class="nav-logo">
|
||||||
<img
|
<img
|
||||||
src="/assets/logo"
|
src="/assets/logo"
|
||||||
alt="${ctx.$app.manifest.name} - logo"
|
alt="${ctx.$app.manifest.name} - logo"
|
||||||
width="50"
|
width="50"
|
||||||
height="50"
|
height="50"
|
||||||
/>
|
/>
|
||||||
Sealious Playground
|
Sealious App
|
||||||
</a>
|
</a>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/account/create">Register</a></li>
|
<li><a href="/logowanie">Logowanie</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>`;
|
</nav>`;
|
||||||
}
|
}
|
||||||
|
@ -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 Router from "@koa/router";
|
||||||
import { Middlewares } from "sealious";
|
import { Middlewares } from "sealious";
|
||||||
import { accountsRouter } from "./account/account.routes";
|
|
||||||
import { MainView } from "./common/main-view";
|
import { MainView } from "./common/main-view";
|
||||||
import { loginRouter } from "./login/login.routes";
|
import { loginRouter } from "./login/login.routes";
|
||||||
import { tasksRouter } from "./tasks/tasks.routes";
|
import mountAutoRoutes from "./routes";
|
||||||
|
|
||||||
export const mainRouter = (router: Router): void => {
|
export const mainRouter = (router: Router): void => {
|
||||||
router.get("/", Middlewares.extractContext(), async (ctx) => {
|
router.get("/", Middlewares.extractContext(), async (ctx) => {
|
||||||
ctx.body = MainView(ctx);
|
ctx.body = MainView(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.use(Middlewares.extractContext());
|
||||||
|
|
||||||
loginRouter(router);
|
loginRouter(router);
|
||||||
tasksRouter(router);
|
mountAutoRoutes(router);
|
||||||
accountsRouter(router);
|
|
||||||
};
|
};
|
||||||
|
@ -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"));
|
||||||
|
}
|
@ -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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue