added collections back to playground
Summary: Ref <T2485> Reviewers: #reviewers, Etoo Subscribers: kuba-orlik, jenkins-user Maniphest Tasks: T2485 Differential Revision: https://hub.sealcode.org/D1217master
parent
942c9b443d
commit
9b9f1df7a8
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
||||
describe("collections", () => {
|
||||
require("./password-reset-intents.subtest");
|
||||
require("./registration-intents.subtest");
|
||||
require("./user-roles.subtest");
|
||||
require("./users.subtest");
|
||||
});
|
@ -0,0 +1,89 @@
|
||||
import axios from "axios";
|
||||
import assert from "assert";
|
||||
import TheApp from "../app";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
describe("password-reset-intents", () => {
|
||||
async function createAUser(app: TheApp) {
|
||||
await app.collections.users.suCreate({
|
||||
username: "user",
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
roles: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("tells you if the email address doesn't exist", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
const email = "fake@example.com";
|
||||
try {
|
||||
await axios.post(
|
||||
`${base_url}/api/v1/collections/password-reset-intents`,
|
||||
{
|
||||
email: email,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
assert.equal(
|
||||
e.response.data.data.field_messages.email.message,
|
||||
app.i18n("invalid_existing_value", ["users", "email", email])
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("it didn't throw");
|
||||
}));
|
||||
|
||||
it("allows anyone to create an intent, if the email exists", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
await createAUser(app);
|
||||
const { email, token } = (
|
||||
await axios.post(
|
||||
`${base_url}/api/v1/collections/password-reset-intents`,
|
||||
{
|
||||
email: "user@example.com",
|
||||
}
|
||||
)
|
||||
).data;
|
||||
assert.deepEqual(
|
||||
{ email, token },
|
||||
{
|
||||
email: "user@example.com",
|
||||
token: "it's a secret to everybody",
|
||||
}
|
||||
);
|
||||
}));
|
||||
|
||||
it("tells you if the email address is malformed", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
const email = "incorrect-address";
|
||||
try {
|
||||
await axios.post(
|
||||
`${base_url}/api/v1/collections/password-reset-intents`,
|
||||
{
|
||||
email: email,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
assert.equal(
|
||||
e.response.data.data.field_messages.email.message,
|
||||
app.i18n("invalid_email", [email])
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("it didn't throw");
|
||||
}));
|
||||
|
||||
it("sends an email with the reset password link", async () =>
|
||||
withProdApp(async ({ app, base_url, mail_api }) => {
|
||||
await createAUser(app);
|
||||
await axios.post(`${base_url}/api/v1/collections/password-reset-intents`, {
|
||||
email: "user@example.com",
|
||||
});
|
||||
const messages = (await mail_api.getMessages()).filter(
|
||||
(message) => message.recipients[0] == "<user@example.com>"
|
||||
);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].recipients.length, 1);
|
||||
assert.equal(messages[0].recipients[0], "<user@example.com>");
|
||||
}));
|
||||
});
|
@ -0,0 +1,39 @@
|
||||
import { App, Collection, CollectionItem, Context, FieldTypes, Policies } from "sealious";
|
||||
import PasswordResetTemplate from "../email-templates/password-reset";
|
||||
import TheApp from "../app";
|
||||
|
||||
export default class PasswordResetIntents extends Collection {
|
||||
name = "password-reset-intents";
|
||||
fields = {
|
||||
email: new FieldTypes.ValueExistingInCollection({
|
||||
field: "email",
|
||||
collection: "users",
|
||||
include_forbidden: true,
|
||||
}),
|
||||
token: new FieldTypes.SecretToken(),
|
||||
};
|
||||
policies = {
|
||||
create: new Policies.Public(),
|
||||
edit: new Policies.Noone(),
|
||||
};
|
||||
defaultPolicy: Policies.Super;
|
||||
async init(app: App, name: string) {
|
||||
const theApp = app as TheApp;
|
||||
await super.init(app, name);
|
||||
app.collections["password-reset-intents"].on(
|
||||
"after:create",
|
||||
async ([context, intent]: [
|
||||
Context,
|
||||
CollectionItem<PasswordResetIntents>,
|
||||
any
|
||||
]) => {
|
||||
const intent_as_super = await intent.fetchAs(new app.SuperContext());
|
||||
const message = await PasswordResetTemplate(theApp, {
|
||||
email_address: intent.get("email") as string,
|
||||
token: intent_as_super.get("token") as string,
|
||||
});
|
||||
await message.send(app);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
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");
|
||||
}));
|
||||
});
|
@ -0,0 +1,43 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { Collection, FieldTypes } from "sealious";
|
||||
import { Roles } from "../policy-types/roles";
|
||||
|
||||
/* For testing the Roles policy */
|
||||
export class Secrets extends Collection {
|
||||
fields = {
|
||||
content: new FieldTypes.Text(),
|
||||
};
|
||||
defaultPolicy = new Roles(["admin"]);
|
||||
}
|
||||
|
||||
export default new Secrets();
|
@ -0,0 +1,71 @@
|
||||
import assert from "assert";
|
||||
import axios from "axios";
|
||||
import { CollectionItem, TestUtils } from "sealious";
|
||||
import { Users } from "./users";
|
||||
import TheApp from "../app";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
function createAUser(app: TheApp, username: string) {
|
||||
return app.collections.users.suCreate({
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password: "password",
|
||||
roles: [],
|
||||
});
|
||||
}
|
||||
|
||||
type Unpromisify<T> = T extends Promise<infer R> ? R : T;
|
||||
|
||||
async function createAdmin(
|
||||
app: TheApp,
|
||||
rest_api: TestUtils.MockRestApi
|
||||
): Promise<[CollectionItem<Users>, Unpromisify<ReturnType<typeof rest_api.login>>]> {
|
||||
const user = await createAUser(app, "super_user");
|
||||
await app.collections["user-roles"].suCreate({
|
||||
user: user.id,
|
||||
role: "admin",
|
||||
});
|
||||
const session = await rest_api.login({
|
||||
username: "super_user",
|
||||
password: "password",
|
||||
});
|
||||
return [user, session];
|
||||
}
|
||||
|
||||
describe("user-roles", () => {
|
||||
it("rejects when given an empty role", async () =>
|
||||
withProdApp(async ({ app, rest_api }) => {
|
||||
const [user, session] = await createAdmin(app, rest_api);
|
||||
await TestUtils.assertThrowsAsync(
|
||||
async () => {
|
||||
return rest_api.post(
|
||||
`/api/v1/collections/user-roles`,
|
||||
{
|
||||
user: user.id,
|
||||
},
|
||||
session
|
||||
);
|
||||
},
|
||||
(e: any) => {
|
||||
assert.equal(
|
||||
e?.response.data.data.field_messages.role?.message,
|
||||
"Missing value for field 'role'."
|
||||
);
|
||||
}
|
||||
);
|
||||
}));
|
||||
|
||||
it("accepts correct dataset", async () =>
|
||||
withProdApp(async ({ app, base_url, rest_api }) => {
|
||||
const [user, session] = await createAdmin(app, rest_api);
|
||||
const response = await axios.post(
|
||||
`${base_url}/api/v1/collections/user-roles`,
|
||||
{
|
||||
user: user.id,
|
||||
role: "admin",
|
||||
},
|
||||
session
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
}));
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import { ActionName, App, Collection, FieldTypes, Policies, Policy } from "sealious";
|
||||
import { Roles } from "../policy-types/roles";
|
||||
|
||||
export class UserRoles extends Collection {
|
||||
name = "user-roles";
|
||||
fields = {
|
||||
role: new FieldTypes.Enum((app: App) =>
|
||||
app.ConfigManager.get("roles")
|
||||
).setRequired(true),
|
||||
user: new FieldTypes.SingleReference("users"),
|
||||
};
|
||||
|
||||
policies = {
|
||||
create: new Roles(["admin"]),
|
||||
delete: new Policies.Public(),
|
||||
show: new Policies.UserReferencedInField("user"),
|
||||
edit: new Policies.Noone(),
|
||||
} as { [policy: string]: Policy }; // this `as` statement allows the policies to be overwritten;
|
||||
|
||||
async init(app: App, collection_name: string) {
|
||||
await super.init(app, collection_name);
|
||||
app.on("started", async () => {
|
||||
const roles = app.collections["user-roles"];
|
||||
for (const action of ["create", "delete"] as ActionName[]) {
|
||||
const policy = roles.getPolicy(action);
|
||||
if (policy instanceof Policies.Public) {
|
||||
app.Logger.warn(
|
||||
"USER POLICY",
|
||||
`<user-roles> collection is using <public> access strategy for ${action} action. Anyone can change anyone elses role. This is the default behavior and you should overwrite it with <set_policy>`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new UserRoles();
|
@ -0,0 +1,20 @@
|
||||
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,52 @@
|
||||
import { App, Collections, FieldTypes } from "sealious";
|
||||
import TheApp from "../app";
|
||||
|
||||
export class Users extends Collections.users {
|
||||
fields = {
|
||||
...App.BaseCollections.users.fields,
|
||||
email: new FieldTypes.Email().setRequired(true),
|
||||
roles: new FieldTypes.ReverseSingleReference({
|
||||
referencing_collection: "user-roles",
|
||||
referencing_field: "user",
|
||||
}),
|
||||
};
|
||||
|
||||
async init(app: TheApp, name: string) {
|
||||
await super.init(app, name);
|
||||
app.on("started", async () => {
|
||||
const users = await app.collections.users
|
||||
.suList()
|
||||
.filter({ email: app.manifest.admin_email })
|
||||
.fetch();
|
||||
if (users.empty) {
|
||||
app.Logger.warn(
|
||||
"ADMIN",
|
||||
`Creating an admin account for ${app.manifest.admin_email}`
|
||||
);
|
||||
await app.collections["registration-intents"].suCreate({
|
||||
email: app.manifest.admin_email,
|
||||
role: "admin",
|
||||
token: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async populate(): Promise<void> {
|
||||
if (await this.app.Metadata.get("my_collection_populated")) {
|
||||
return;
|
||||
}
|
||||
const app = this.app as TheApp;
|
||||
|
||||
await app.collections.users.suCreate({
|
||||
email: "admin@example.com",
|
||||
roles: [],
|
||||
username: "admin",
|
||||
password: "password",
|
||||
});
|
||||
|
||||
await this.app.Metadata.set("my_collection_populated", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export default new Users();
|
@ -0,0 +1,31 @@
|
||||
import { App, EmailTemplates, Errors } from "sealious";
|
||||
import TheApp from "../app";
|
||||
|
||||
export default async function PasswordResetTemplate(
|
||||
app: TheApp,
|
||||
{ email_address, token }: { email_address: string; token: string }
|
||||
) {
|
||||
const matching_users = await app.collections["users"]
|
||||
.suList()
|
||||
.filter({ email: email_address })
|
||||
.fetch();
|
||||
|
||||
if (!matching_users.items.length) {
|
||||
throw new Errors.NotFound("No user with that email");
|
||||
}
|
||||
|
||||
const username = matching_users.items[0].get("username");
|
||||
|
||||
return EmailTemplates.Simple(app, {
|
||||
subject: app.i18n("password_reset_email_subject", [app.manifest.name]),
|
||||
to: `${username}<${email_address}>`,
|
||||
text: `
|
||||
${app.i18n("password_reset_email_text", [app.manifest.name, username])}`,
|
||||
buttons: [
|
||||
{
|
||||
text: app.i18n("password_reset_cta"),
|
||||
href: `${app.manifest.base_url}/confirm-password-reset?token=${token}&email=${email_address}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { App, EmailTemplates } from "sealious";
|
||||
|
||||
export default async function RegistrationIntentTemplate(
|
||||
app: App,
|
||||
{ email_address, token }: { email_address: string; token: string }
|
||||
) {
|
||||
return EmailTemplates.Simple(app, {
|
||||
subject: app.i18n("registration_intent_email_subject", [app.manifest.name]),
|
||||
to: email_address,
|
||||
text: `
|
||||
${app.i18n("registration_intent_email_text", [app.manifest.name])}`,
|
||||
buttons: [
|
||||
{
|
||||
text: app.i18n("registration_intent_cta"),
|
||||
href: `${app.manifest.base_url}/account/confirm-registration-email?token=${token}&email=${email_address}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
describe("policy-types", () => {
|
||||
require("./roles.subtest");
|
||||
});
|
@ -0,0 +1,58 @@
|
||||
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,55 @@
|
||||
import { Context, Policy, QueryTypes } from "sealious";
|
||||
|
||||
export class Roles extends Policy {
|
||||
static type_name = "roles";
|
||||
allowed_roles: string[];
|
||||
constructor(allowed_roles: string[]) {
|
||||
super(allowed_roles);
|
||||
this.allowed_roles = allowed_roles;
|
||||
}
|
||||
|
||||
async countMatchingRoles(context: Context) {
|
||||
const user_id = context.user_id;
|
||||
context.app.Logger.debug2("ROLES", "Checking the roles for user", user_id);
|
||||
const user_roles = await context.app.collections["user-roles"]
|
||||
.list(context)
|
||||
.filter({ user: user_id })
|
||||
.fetch();
|
||||
const roles = user_roles.items.map((user_role) => user_role.get("role"));
|
||||
|
||||
return this.allowed_roles.filter((allowed_role) => roles.includes(allowed_role))
|
||||
.length;
|
||||
}
|
||||
|
||||
async _getRestrictingQuery(context: Context) {
|
||||
if (context.is_super) {
|
||||
return new QueryTypes.AllowAll();
|
||||
}
|
||||
if (context.user_id === null) {
|
||||
return new QueryTypes.DenyAll();
|
||||
}
|
||||
|
||||
const matching_roles_count = await this.countMatchingRoles(context);
|
||||
|
||||
return matching_roles_count > 0
|
||||
? new QueryTypes.AllowAll()
|
||||
: new QueryTypes.DenyAll();
|
||||
}
|
||||
|
||||
async checkerFunction(context: Context) {
|
||||
if (context.user_id === null) {
|
||||
return Policy.deny(context.app.i18n("policy_logged_in_deny"));
|
||||
}
|
||||
const matching_roles_count = await this.countMatchingRoles(context);
|
||||
|
||||
return matching_roles_count > 0
|
||||
? Policy.allow(
|
||||
context.app.i18n("policy_roles_allow", [
|
||||
this.allowed_roles.join(", "),
|
||||
])
|
||||
)
|
||||
: Policy.deny(
|
||||
context.app.i18n("policy_roles_deny", [this.allowed_roles.join(", ")])
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
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);
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
describe("routes", () => {
|
||||
// require("./finalize-registration-intent.subtest");
|
||||
// require("./finalize-password-reset.subtest");
|
||||
require("./confirm-password-reset.subtest");
|
||||
require("./account-creation-details.subtest");
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
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`
|
||||
);
|
||||
}));
|
||||
});
|
@ -0,0 +1,80 @@
|
||||
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;
|
@ -0,0 +1,68 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
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);
|
||||
}));
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
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>
|
||||
`
|
||||
);
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
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>`
|
||||
);
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
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");
|
||||
}
|
||||
);
|
||||
}));
|
||||
});
|
@ -0,0 +1,52 @@
|
||||
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;
|
@ -0,0 +1,43 @@
|
||||
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");
|
||||
}));
|
||||
});
|
@ -0,0 +1,53 @@
|
||||
import { Middleware } from "@koa/router";
|
||||