Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions docs/docs/concepts/frontends.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The sign-in it offers is the same either way, because the package ships one add-
`@plone-collective/aurora-identity`
: The Plone Aurora add-on.
Sign-in: the login page, starting a sign-in with a provider, and finishing it.
For a signed-in user, the sign-in methods page, `/identities`, and its entry among the header's tools.
For a signed-in user, the sign-in methods page, `/identities`, and its entry among the header's tools, the email confirmation page, and the profile gate.

<!-- frontend/packages/identity-core/src/index.ts, frontend/packages/aurora-identity/index.ts -->

Expand Down Expand Up @@ -134,10 +134,30 @@ They are signed-in pages: a visitor without a session is sent to `/login`.

<!-- frontend/packages/aurora-identity/lib/routes.ts, frontend/packages/aurora-identity/routes/identities.tsx -->

## The profile gate

While a signed-in user's profile is missing required fields, both add-ons hold them until it is complete, then send them on to where they were going.
They decide it the same way, with the core's helpers, and hold the user differently:

| | Volto | Aurora |
|---|---|---|
| Sent to | The profile's edit form | `/complete-profile`, which names the missing fields and links to the edit form |
| Told why | A toast, as it redirects | On that page |
| The profile asked for | As an expansion of the content request Volto makes anyway | With a request of its own, on every page a signed-in user opens |

Aurora shows no toast that every page carries, and its edit form belongs to `@plone/cmsui`, where the add-on cannot say anything, so the page is what explains the hold.
Its content request expands a fixed list an add-on cannot add to, so the profile costs one more request.

The gate runs on every page that has the site's header, from the header's tools.
A user who completes the profile through the edit form is let go on the profile's own page, which the form saves to.

<!-- frontend/packages/aurora-identity/lib/gate.ts, frontend/packages/aurora-identity/slots/ProfileGate.tsx, frontend/packages/aurora-identity/config/server.ts, frontend/packages/volto-identity/src/components/ProfileGate/ProfileGate.tsx -->

## What Aurora does not have yet

The Aurora add-on covers signing in and the sign-in methods page.
The first-login and email confirmation pages, the required-profile gate, the applications page, the consent screen and the control panels exist in the Volto add-on only.
The Aurora add-on covers signing in, the sign-in methods page, the email confirmation page and the profile gate.
The applications page, the consent screen and the control panels exist in the Volto add-on only.
Neither has the first-login route: Volto offers it to sites that route to it, and nothing in either add-on does.
Neither the Aurora add-on nor the core is published to npm yet.

## Related
Expand Down
72 changes: 72 additions & 0 deletions frontend/aurora/acceptance/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,75 @@ export async function lastMagicLink(address: string): Promise<string | null> {
}
return message.match(/https?:\/\/\S*[?&]magic_link=[\w.-]+/)?.[0] ?? null;
}

/** The registry record naming the fields a profile must carry. */
const REQUIRED_FIELDS = 'pas.plugins.identity.required_profile_fields';

/**
* Name the fields a profile must carry to count as complete.
*
* The backend evaluates it at every sign-in and every edit of a profile, so
* a change reaches a profile at its owner's next sign-in.
*
* @param fields The field names; none to require only what the type does.
*/
export async function requireProfileFields(fields: string[]): Promise<void> {
const answer = await fetch(`${SITE}/++api++/@registry`, {
method: 'PATCH',
headers,
body: JSON.stringify({ [REQUIRED_FIELDS]: fields }),
});
if (!answer.ok) {
throw new Error(
`Setting the required fields failed: ${answer.status} ${await answer.text()}`,
);
}
}

/**
* The path of the profile carrying an address, if there is one yet.
*
* @param address The address.
* @returns The profile's path below the site, or null.
*/
export async function profileOf(address: string): Promise<string | null> {
const search = await fetch(
`${SITE}/++api++/@search?portal_type=UserProfile&b_size=100`,
{ headers },
);
const { items } = (await search.json()) as { items: { '@id': string }[] };
for (const item of items) {
const path = new URL(item['@id']).pathname.replace(
new URL(SITE).pathname,
'',
);
const profile = await fetch(`${SITE}/++api++${path}`, { headers });
const { emails } = (await profile.json()) as { emails?: string[] };
if (emails?.includes(address)) {
return path;
}
}
return null;
}

/**
* Edit a profile, as an administrator.
*
* @param path The profile's path below the site.
* @param fields The fields to set.
*/
export async function editProfile(
path: string,
fields: Record<string, unknown>,
): Promise<void> {
const answer = await fetch(`${SITE}/++api++${path}`, {
method: 'PATCH',
headers,
body: JSON.stringify(fields),
});
if (!answer.ok) {
throw new Error(
`Editing ${path} failed: ${answer.status} ${await answer.text()}`,
);
}
}
46 changes: 46 additions & 0 deletions frontend/aurora/acceptance/tests/profile-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';

import { editProfile, profileOf, requireProfileFields } from '../backend';
import { DEX_USER, signInWithDex } from '../dex';

// A field the Dex user's profile has no value for after a sign-in. Required
// only for these tests: the others sign the same user in, and must not be
// held.
test.beforeAll(async () => {
await requireProfileFields(['description']);
// A run before this one may have filled it in.
const path = await profileOf(DEX_USER.email);
if (path) {
await editProfile(path, { description: '' });
}
});
test.afterAll(() => requireProfileFields([]));

test.describe("Aurora's profile gate", () => {
test('holds an incomplete profile, and lets it go once complete', async ({
page,
}) => {
await signInWithDex(page);

// Held, and told why, by the label the form gives the field.
await expect(page).toHaveURL(/\/complete-profile$/);
await expect(page.getByRole('status')).toContainText(
/Please fill in .+ before you can continue\./,
);
await expect(
page.getByRole('link', { name: 'Edit your profile' }),
).toHaveAttribute('href', /^\/@@edit\//);

// Wherever they go, they are held, and the gate remembers where.
await page.goto('/search');
await expect(page).toHaveURL(/\/complete-profile$/);

// Completed -- here by an administrator, where a person would use the
// form -- the next page lets them through, on to where they were going.
const path = await profileOf(DEX_USER.email);
expect(path).not.toBeNull();
await editProfile(path!, { description: 'Filled in by the tests.' });
await page.goto('/');
await expect(page).toHaveURL(/\/search$/);
});
});
45 changes: 45 additions & 0 deletions frontend/packages/aurora-identity/config/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* The add-on's server-only configuration.
*
* Aurora loads `config/server.ts` from every add-on into the server bundle
* alone, which is where a `rootLoaderData` utility belongs.
* @module config/server
*/
import type { ConfigType } from '@plone/registry';
import { getAuthFromRequest } from '@plone/react-router';
import { endpoints } from '@plone-collective/identity-core';

import { callBackend } from '../lib/api';
import { PROFILE_KEY } from '../lib/gate';

export default function install(config: ConfigType) {
// The signed-in user's `@my-profile`, for the profile gate, on every page.
//
// One request per signed-in page. The Volto add-on gets the same answer
// for free, as an expansion of the content request it was making anyway;
// Aurora's content request expands a fixed list an add-on cannot add to.
config.registerUtility({
name: 'IdentityProfileGate',
type: 'rootLoaderData',
method: async ({ request }: { request: Request }) => {
const token = await getAuthFromRequest(request);
if (!token) {
return { status: 200, data: {} };
}
try {
const answer = await callBackend(request, endpoints.myProfile(), {
token,
});
if (!answer.ok) {
// A backend that cannot answer must not make the site
// unreachable: without an answer, the gate lets everybody through.
return { status: answer.status, data: {} };
}
return { status: 200, data: { [PROFILE_KEY]: await answer.json() } };
} catch {
return { status: 502, data: {} };
}
},
});
return config;
}
57 changes: 43 additions & 14 deletions frontend/packages/aurora-identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
*
* Sign-in with external identity providers: a login page offering them, the
* route that starts a sign-in with one of them, and the route they send the
* browser back to. And for a signed-in user, the page that manages their
* sign-in methods, with its entry among their tools.
* browser back to. And for a signed-in user: the page that manages their
* sign-in methods, with its entry among their tools, and the profile gate,
* which holds them until their profile is complete.
* @module aurora-identity
*/
import type { ConfigType } from '@plone/registry';

import IdentityTools from './slots/IdentityTools';
import { CALLBACK_PATH, IDENTITIES_PATH, START_PATH } from './lib/paths';
import ProfileGate from './slots/ProfileGate';
import { COMPLETE_PROFILE_PATH } from './lib/gate';
import {
CALLBACK_PATH,
CONFIRM_EMAIL_PATH,
IDENTITIES_PATH,
START_PATH,
} from './lib/paths';
import {
addRouteUnder,
AURORA_LOGIN_FILE,
Expand All @@ -23,13 +31,27 @@ import type { IdentitySettings } from './lib/settings';
/** The add-on's login page, rendered at Aurora's `/login`. */
const LOGIN_FILE = '@plone-collective/aurora-identity/routes/login.tsx';

/** The sign-in methods page. */
const IDENTITIES_ROUTE = {
type: 'route' as const,
path: IDENTITIES_PATH.slice(1),
file: '@plone-collective/aurora-identity/routes/identities.tsx',
options: { id: 'identity-identities' },
};
/** The signed-in user's pages, inside the site's frame. */
const USER_ROUTES = [
{
type: 'route' as const,
path: IDENTITIES_PATH.slice(1),
file: '@plone-collective/aurora-identity/routes/identities.tsx',
options: { id: 'identity-identities' },
},
{
type: 'route' as const,
path: CONFIRM_EMAIL_PATH.slice(1),
file: '@plone-collective/aurora-identity/routes/confirm-email.tsx',
options: { id: 'identity-confirm-email' },
},
{
type: 'route' as const,
path: COMPLETE_PROFILE_PATH.slice(1),
file: '@plone-collective/aurora-identity/routes/complete-profile.tsx',
options: { id: 'identity-complete-profile' },
},
];

export default function install(config: ConfigType) {
// Merged under whatever is already there: these are defaults, and an
Expand All @@ -50,16 +72,23 @@ export default function install(config: ConfigType) {

// Inside the site's frame, with its header, so the user menu that leads
// here also leads back. Without publicui, at the top of the tree.
if (
!addRouteUnder(config.routes ?? [], PUBLIC_LAYOUT_FILE, IDENTITIES_ROUTE)
) {
config.registerRoute(IDENTITIES_ROUTE);
for (const route of USER_ROUTES) {
if (!addRouteUnder(config.routes ?? [], PUBLIC_LAYOUT_FILE, route)) {
config.registerRoute(route);
}
}
config.registerSlotComponent({
name: 'IdentityTools',
slot: 'authenticatedTools',
component: IdentityTools,
});
// The profile gate, on every page with the site's header. It draws
// nothing; the slot is only where every signed-in page mounts it.
config.registerSlotComponent({
name: 'IdentityProfileGate',
slot: 'authenticatedTools',
component: ProfileGate,
});

// Path segments without their leading slash, the way Aurora's own routes
// are registered.
Expand Down
56 changes: 56 additions & 0 deletions frontend/packages/aurora-identity/lib/gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { MyProfile } from '@plone-collective/identity-core';

import { COMPLETE_PROFILE_PATH, gateTarget, profileEditPath } from './gate';

const HELD: MyProfile = {
'@id': 'http://localhost:3000/@my-profile',
userid: 'alice',
profile: 'http://localhost:3000/profiles/alice',
review_state: 'incomplete',
missing: ['organisation'],
};

describe('gateTarget', () => {
it('lets through a user without a profile, or with a complete one', () => {
expect(gateTarget(null, '/news')).toBeNull();
expect(gateTarget({ ...HELD, profile: null }, '/news')).toBeNull();
expect(
gateTarget({ ...HELD, review_state: 'complete' }, '/news'),
).toBeNull();
});

it('sends a held user to the explanation', () => {
expect(gateTarget(HELD, '/news')).toBe(COMPLETE_PROFILE_PATH);
expect(gateTarget(HELD, '/')).toBe(COMPLETE_PROFILE_PATH);
});

it('leaves them on the explanation, the profile and its edit form', () => {
expect(gateTarget(HELD, COMPLETE_PROFILE_PATH)).toBeNull();
expect(gateTarget(HELD, '/profiles/alice')).toBeNull();
expect(gateTarget(HELD, '/@@edit/profiles/alice')).toBeNull();
});

it('never holds them on the way in or out', () => {
for (const path of ['/login', '/logout', '/login-identity']) {
expect(gateTarget(HELD, path)).toBeNull();
}
});

it('asks for the address first when that is all that is missing', () => {
const confirming = { ...HELD, missing: [], confirm_email: true };

expect(gateTarget(confirming, '/news')).toBe('/confirm-email');
// Even from the profile, whose form cannot answer it.
expect(gateTarget(confirming, '/profiles/alice')).toBe('/confirm-email');
expect(gateTarget(confirming, '/confirm-email')).toBeNull();
});
});

describe('profileEditPath', () => {
it("is Aurora's edit form for the profile's path", () => {
expect(profileEditPath('http://localhost:3000/profiles/alice')).toBe(
'/@@edit/profiles/alice',
);
});
});
Loading
Loading