Skip to content

Commit

Permalink
refactor: Authenticated Pages and Layouts (#4978)
Browse files Browse the repository at this point in the history
* securityQuestions lib

* security questions tests update

* remove AccessControlError from barrel file.

* update securityQuestions actions

* update flags lib

* invitations and users

* move unauthorized page

* appSettings and authenticated actions

* fix: add authentication check to checkIfClosed server action

* chore: AuthenticatedAction wrapper pass session when calling inner action

* fix: prevent user from requesting permission to publish forms if they already have it

* fix: add missing authentication check in settings server actions

* fix: add missing authentication + privilege checks in manage server actions

* chore: migrate all appropriate server actions to use the new AuthenticatedAction wrapper

* feat: add missing audit logs when modifying throttling rate on a form set to deliver responses through API method

* chore: remove unnecessary use of getAbility function

* fix: broken unit tests due to refactoring around getAbility

* chore: refactor custom AccessControlError class to avoid using a promise to get the user identifier

* Authenticate Page and Layout

* fix: preview page should handle non authenticated users

* Authenticated Layout and Pages wrapper

* update unlock-publishing

* update typing

* clean up

* add type check

* align with privileges requested on page

* Align thottling privileges with page

* remove force typing

* fix logic  on publish priv check

* Add helper to check for publish forms priv

* refactor - rename authorization publish priv check

* Add auth wrapper to unauthorized page

* refactor: convert checkIfClosed function from server action to simple lib function

* fix: misuse of new AccessControlError constructor

* chore: remove useless mocks

* refactor: delete unused server actions

* refactor: delete unused refreshKey server action/lib function

* refactor: remove not needed server action for view templates page

* refactor: remove not needed server action for view accounts page

* refactor: remove not needed server action for application settings page

* refactor: remove not needed server action for form settings page

* refactor: convert some server actions to regular lib functions

---------

Co-authored-by: Clément Janin <[email protected]>
  • Loading branch information
bryan-robitaille and craigzour authored Jan 23, 2025
1 parent f7ae839 commit 4c9bcf2
Show file tree
Hide file tree
Showing 82 changed files with 1,643 additions and 2,030 deletions.
14 changes: 8 additions & 6 deletions __utils__/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,30 @@ jest.mock("@lib/privileges");

import { authorization, getAbility } from "@lib/privileges";
import { UserAbility } from "@lib/types";
import { AccessControlError } from "@lib/auth";
import { AccessControlError } from "@lib/auth/errors";

type MockedAuthFunction = {
[key: string]: jest.Mock;
};

export const mockAuthorizationPass = (userID: string) => {
export const mockAuthorizationPass = (userId: string) => {
const mockedAuth: MockedAuthFunction = jest.mocked(authorization);
for (const property in authorization) {
mockedAuth[property] = jest
.fn()
.mockImplementation(() => Promise.resolve({ user: { id: userID } }));
.mockImplementation(() => Promise.resolve({ user: { id: userId } }));
}
};

export const mockAuthorizationFail = (userID: string) => {
export const mockAuthorizationFail = (userId: string) => {
const mockedAuth: MockedAuthFunction = jest.mocked(authorization);
mockGetAbility(userID);
mockGetAbility(userId);
for (const property in authorization) {
mockedAuth[property] = jest
.fn()
.mockImplementation(() => Promise.reject(new AccessControlError()));
.mockImplementation(() =>
Promise.reject(new AccessControlError(userId, "AccessControlError"))
);
}
};

Expand Down
5 changes: 0 additions & 5 deletions __utils__/mocks/server-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ jest.mock("@formBuilder/actions", () => ({
getTranslatedProperties: jest.fn(),
}));

jest.mock("@formBuilder/actions", () => ({
__esModule: true,
checkFlag: jest.fn(),
}));

jest.mock("@lib/actions/auth", () => ({
__esModule: true,
authCheckAndThrow: jest.fn(),
Expand Down
112 changes: 56 additions & 56 deletions app/(gcforms)/[locale]/(app administration)/admin/(no nav)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,70 +6,70 @@ import { SiteLogo } from "@serverComponents/icons";

import LanguageToggle from "@serverComponents/globals/LanguageToggle";
import { YourAccountDropdown } from "@clientComponents/globals/Header/YourAccountDropdown";
import { authCheckAndRedirect } from "@lib/actions";

import { SkipLink } from "@serverComponents/globals/SkipLink";
import { Footer } from "@serverComponents/globals/Footer";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const params = await props.params;

const { locale } = params;

const { children } = props;
import { AuthenticatedLayout } from "@lib/pages/auth";
import { authorization } from "@lib/privileges";

await authCheckAndRedirect();
export default AuthenticatedLayout(
[authorization.hasAdministrationPrivileges],
async ({ children, params }) => {
const { locale } = await params;

const { t } = await serverTranslation(["common", "admin-login"], { lang: locale });
const { t } = await serverTranslation(["common", "admin-login"], { lang: locale });

return (
<div className={`flex h-full flex-col bg-gray-50`}>
<div className="flex h-full flex-col">
<SkipLink />
<header className={"mb-5 border-b-1 border-gray-500 bg-white px-0 py-2"}>
<div className="grid w-full grid-flow-col">
<div className="flex">
<Link href={`/${locale}/form-builder`} legacyBehavior>
<a
id="logo"
className="mr-5 flex border-r-1 pr-[0.77rem] text-3xl font-semibold !text-black no-underline focus:bg-white"
>
<div className="inline-block h-[45px] w-[46px] p-2">
<SiteLogo title={t("title")} />
</div>
</a>
</Link>
return (
<div className={`flex h-full flex-col bg-gray-50`}>
<div className="flex h-full flex-col">
<SkipLink />
<header className={"mb-5 border-b-1 border-gray-500 bg-white px-0 py-2"}>
<div className="grid w-full grid-flow-col">
<div className="flex">
<Link href={`/${locale}/form-builder`} legacyBehavior>
<a
id="logo"
className="mr-5 flex border-r-1 pr-[0.77rem] text-3xl font-semibold !text-black no-underline focus:bg-white"
>
<div className="inline-block h-[45px] w-[46px] p-2">
<SiteLogo title={t("title")} />
</div>
</a>
</Link>

<div className="mt-3 box-border block h-[40px] px-2 py-1 text-xl font-semibold">
{t("title", { ns: "admin-login" })}
<div className="mt-3 box-border block h-[40px] px-2 py-1 text-xl font-semibold">
{t("title", { ns: "admin-login" })}
</div>
</div>
<nav
className="justify-self-end"
aria-label={t("mainNavAriaLabel", { ns: "common" })}
>
<ul className="mt-2 flex list-none px-0 text-base">
<li className="mr-2 py-2 text-base tablet:mr-4">
<Link href={`/${locale}/forms`}>{t("adminNav.myForms", { ns: "common" })}</Link>
</li>
<li className="mr-2 py-2 tablet:mr-4">
<LanguageToggle />
</li>
<li className="mr-5 text-base">
<YourAccountDropdown isAuthenticated={true} />
</li>
</ul>
</nav>
</div>
<nav className="justify-self-end" aria-label={t("mainNavAriaLabel", { ns: "common" })}>
<ul className="mt-2 flex list-none px-0 text-base">
<li className="mr-2 py-2 text-base tablet:mr-4">
<Link href={`/${locale}/forms`}>{t("adminNav.myForms", { ns: "common" })}</Link>
</li>
<li className="mr-2 py-2 tablet:mr-4">
<LanguageToggle />
</li>
<li className="mr-5 text-base">
<YourAccountDropdown isAuthenticated={true} />
</li>
</ul>
</nav>
</header>
<div className="mx-4 shrink-0 grow basis-auto laptop:mx-32 desktop:mx-64">
<ToastContainer />
<>
<div>
<main id="content">{children}</main>
</div>
</>
</div>
</header>
<div className="mx-4 shrink-0 grow basis-auto laptop:mx-32 desktop:mx-64">
<ToastContainer />
<>
<div>
<main id="content">{children}</main>
</div>
</>
<Footer displayFormBuilderFooter />
</div>
<Footer displayFormBuilderFooter />
</div>
</div>
);
}
);
}
);
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { authCheckAndRedirect } from "@lib/actions";
import { checkPrivilegesAsBoolean } from "@lib/privileges";
import { serverTranslation } from "@i18n";
import Link from "next/link";
import { ManageAccountsIcon, SettingsApplicationsIcon } from "@serverComponents/icons";
import { Metadata } from "next";
import { redirect } from "next/navigation";

export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
Expand All @@ -27,16 +24,6 @@ export default async function Page(props: { params: Promise<{ locale: string }>

const { t } = await serverTranslation(["admin-home", "common"]);

const { ability } = await authCheckAndRedirect();

const canViewUsers = checkPrivilegesAsBoolean(ability, [{ action: "view", subject: "User" }], {
redirect: true,
});

if (!canViewUsers) {
redirect(`/${locale}/forms`);
}

return (
<>
<h1 className="visually-hidden">{t("title", { ns: "admin-home" })}</h1>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import JSONUpload from "@clientComponents/admin/JsonUpload/JsonUpload";
import { serverTranslation } from "@i18n";
import { authCheckAndRedirect } from "@lib/actions";
import { checkPrivilegesAsBoolean } from "@lib/privileges";
import { authorization } from "@lib/privileges";
import { Metadata } from "next";

export async function generateMetadata(props: {
Expand All @@ -19,12 +18,10 @@ export async function generateMetadata(props: {

export default async function Page() {
const { t } = await serverTranslation("admin-templates");

const { ability } = await authCheckAndRedirect();

checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "FormRecord" }], {
redirect: true,
await authorization.canManageAllForms().catch(() => {
authorization.unauthorizedRedirect();
});

return (
<>
<h1>{t("upload.title")}</h1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,23 @@

import { getAllTemplates } from "@lib/templates";
import { AuthenticatedAction } from "@lib/actions";
import { FormRecord } from "@lib/types";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

export const getTemplates = AuthenticatedAction(async () => {
const templates = await getAllTemplates();
return filterTemplateProperties(templates);
});

export const getLatestPublishedTemplates = AuthenticatedAction(async () => {
const templates = await getAllTemplates({
requestedWhere: { isPublished: true },
sortByDateUpdated: "desc",
});

return filterTemplateProperties(templates);
});

// Internal and private functions - won't be converted into server actions

const filterTemplateProperties = (templates: FormRecord[]) => {
return templates.map((template) => {
const {
id,
form: { titleEn, titleFr },
isPublished,
updatedAt,
} = template;

return { id, titleEn, titleFr, isPublished, updatedAt };
});
};
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { serverTranslation } from "@i18n";
import { authCheckAndRedirect } from "@lib/actions";
import { checkPrivilegesAsBoolean } from "@lib/privileges";
import { authorization } from "@lib/privileges";
import { Metadata } from "next";
import { DataView } from "./clientSide";
import { getTemplates } from "./actions";
import { getAllTemplates } from "@lib/templates";
import { AuthenticatedPage } from "@lib/pages/auth";

export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
Expand All @@ -18,14 +18,19 @@ export async function generateMetadata(props: {
};
}

export default async function Page() {
const { ability } = await authCheckAndRedirect();
export default AuthenticatedPage([authorization.canManageAllForms], async () => {
const allTemplates = await getAllTemplates();

checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "FormRecord" }], {
redirect: true,
});
const templatesToDataViewObject = allTemplates.map((template) => {
const {
id,
form: { titleEn, titleFr },
isPublished,
updatedAt,
} = template;

const templates = await getTemplates();
return { id, titleEn, titleFr, isPublished, updatedAt };
});

return <DataView templates={templates} />;
}
return <DataView templates={templatesToDataViewObject} />;
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AuthenticatedAction } from "@lib/actions";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

export const deleteForm = AuthenticatedAction(async (id: string) => {
export const deleteForm = AuthenticatedAction(async (_, id: string) => {
try {
await deleteTemplate(id);
revalidatePath("app/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms");
Expand Down
Loading

0 comments on commit 4c9bcf2

Please sign in to comment.