Skip to content

Commit bc3b409

Browse files
authored
feat: add ability to customize the password page (#708)
1 parent e0c32a5 commit bc3b409

10 files changed

Lines changed: 303 additions & 220 deletions

File tree

.changeset/lazy-spies-explode.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-password-protect': patch
3+
---
4+
5+
feat: add ability to customize the password page

examples/blog/root-cms.d.ts

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
/* eslint-disable */
12
/** Root.js CMS types. This file is autogenerated. */
23

3-
export interface RootCMSImage {
4+
export interface RootCMSFile {
45
src: string;
56
width?: number;
67
height?: number;
78
alt?: string;
89
}
910

10-
export type RootCMSOneOf<T = any> = T & {
11-
_type: string;
12-
};
11+
export type RootCMSImage = RootCMSFile;
12+
13+
export type RootCMSOneOf<T = any> = T;
14+
15+
export type RootCMSOneOfOption<T, Base> = Base & {_type: T};
1316

1417
export interface RootCMSRichTextBlock {
1518
type: string;
@@ -84,10 +87,8 @@ export interface BlogPostsFields {
8487
*/
8588
customCss?: string;
8689
/** PDF. PDF version of the post. */
87-
pdf?: {
88-
src: string;
89-
};
90-
/** Published Date Override. Override for the "Published" date. */
90+
pdf?: RootCMSFile;
91+
/** Published Date Override. Override for the 'Published' date. */
9192
publishedAtOverride?: number;
9293
/** Parent Post. Optional parent post for breadcrumbs. */
9394
parentPost?: RootCMSReference;
@@ -131,10 +132,8 @@ export interface BlogPostsSandboxFields {
131132
*/
132133
customCss?: string;
133134
/** PDF. PDF version of the post. */
134-
pdf?: {
135-
src: string;
136-
};
137-
/** Published Date Override. Override for the "Published" date. */
135+
pdf?: RootCMSFile;
136+
/** Published Date Override. Override for the 'Published' date. */
138137
publishedAtOverride?: number;
139138
/** Parent Post. Optional parent post for breadcrumbs. */
140139
parentPost?: RootCMSReference;
@@ -146,6 +145,12 @@ export interface BlogPostsSandboxFields {
146145
/** Generated from `/collections/BlogPostsSandbox.schema.ts`. */
147146
export type BlogPostsSandboxDoc = RootCMSDoc<BlogPostsSandboxFields>;
148147

148+
/** Generated from `/templates/Template5050/5050assets/ImageAsset.schema.ts`. */
149+
export interface ImageAssetFields {
150+
/** Image to embed. Optional. If not provided, the default YT thumbnail is used. */
151+
image?: RootCMSImage;
152+
}
153+
149154
/** Generated from `/collections/Pages.schema.ts`. */
150155
export interface PagesFields {
151156
/** [INTERNAL] Description. Internal-only field. Used for notes, etc. */
@@ -162,7 +167,7 @@ export interface PagesFields {
162167
/** Content */
163168
content?: {
164169
/** Modules. Compose the page by adding one or more modules. */
165-
modules?: RootCMSOneOf<SpacerFields | Template5050Fields | TemplateFeaturedBlogPostsFields | TemplateHeroFields>[];
170+
modules?: RootCMSOneOf<RootCMSOneOfOption<'Spacer', SpacerFields> | RootCMSOneOfOption<'Template5050', Template5050Fields> | RootCMSOneOfOption<'TemplateFeaturedBlogPosts', TemplateFeaturedBlogPostsFields> | RootCMSOneOfOption<'TemplateHero', TemplateHeroFields>>[];
166171
};
167172
/** Advanced */
168173
advanced?: {
@@ -184,20 +189,6 @@ export interface SpacerFields {
184189
mobileHeight?: string;
185190
}
186191

187-
/** Generated from `/templates/Template5050/5050assets/ImageAsset.schema.ts`. */
188-
export interface ImageAssetFields {
189-
/** Image to embed. Optional. If not provided, the default YT thumbnail is used. */
190-
image?: RootCMSImage;
191-
}
192-
193-
/** Generated from `/templates/Template5050/5050assets/YouTubeAsset.schema.ts`. */
194-
export interface YouTubeAssetFields {
195-
/** YouTube URL */
196-
youtubeUrl?: string;
197-
/** Thumbnail image. Optional. If not provided, the default YT thumbnail is used. */
198-
thumbnail?: RootCMSImage;
199-
}
200-
201192
/** Generated from `/templates/Template5050/Template5050.schema.ts`. */
202193
export interface Template5050Fields {
203194
/** ID. Used for deep linking, tracking, etc. */
@@ -207,7 +198,7 @@ export interface Template5050Fields {
207198
/** Body */
208199
body?: string;
209200
/** Asset */
210-
asset?: RootCMSOneOf<ImageAssetFields | YouTubeAssetFields>;
201+
asset?: RootCMSOneOf<RootCMSOneOfOption<'ImageAsset', ImageAssetFields> | RootCMSOneOfOption<'YouTubeAsset', YouTubeAssetFields>>;
211202
}
212203

213204
/** Generated from `/templates/TemplateFeaturedBlogPosts/TemplateFeaturedBlogPosts.schema.ts`. */
@@ -230,4 +221,12 @@ export interface TemplateHeroFields {
230221
title?: string;
231222
/** Image */
232223
image?: RootCMSImage;
233-
}
224+
}
225+
226+
/** Generated from `/templates/Template5050/5050assets/YouTubeAsset.schema.ts`. */
227+
export interface YouTubeAssetFields {
228+
/** YouTube URL */
229+
youtubeUrl?: string;
230+
/** Thumbnail image. Optional. If not provided, the default YT thumbnail is used. */
231+
thumbnail?: RootCMSImage;
232+
}

examples/blog/root.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ export default defineConfig({
4848
{source: '/restricted/[...path]', destination: '/unauthorized'},
4949
],
5050
headers: [
51-
{source: '/test-json.txt', headers: [{key: 'content-type', value: 'application/json'}]},
51+
{
52+
source: '/test-json.txt',
53+
headers: [{key: 'content-type', value: 'application/json'}],
54+
},
5255
],
5356
},
5457
plugins: [
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// This file is autogenerated by update-root-version.mjs.
2-
export const ROOT_VERSION = '2.0.3';
2+
export const ROOT_VERSION = '2.0.10';

packages/root-password-protect/src/cli/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command} from 'commander';
22
import {bgGreen, black} from 'kleur/colors';
3-
import {generateHash} from './generate-hash';
3+
import {generateHash} from './generate-hash.js';
44

55
export class CliRunner {
66
private name: string;

packages/root-password-protect/src/components/PasswordPage/PasswordPage.css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const CSS = `
22
.signin {
3-
font-family: 'Google Sans', arial, sans-serif;
3+
font-family: 'Inter', system-ui, sans-serif;
44
text-align: center;
55
padding: 48px 20px;
66
color: #3c4043;

packages/root-password-protect/src/components/PasswordPage/PasswordPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface PasswordPageProps {
77

88
export function PasswordPage(props: PasswordPageProps) {
99
return (
10-
<html>
10+
<html lang="en">
1111
<head>
1212
<meta charset="utf-8" />
1313
<title>Protected</title>
@@ -17,8 +17,8 @@ export function PasswordPage(props: PasswordPageProps) {
1717
type="image/png"
1818
/>
1919
<link
20+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900"
2021
rel="stylesheet"
21-
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@500&display=swap"
2222
nonce={props.nonce}
2323
/>
2424
<style nonce={props.nonce}>{CSS}</style>
@@ -42,6 +42,7 @@ export function PasswordPage(props: PasswordPageProps) {
4242
name="password"
4343
type="password"
4444
placeholder="Password"
45+
autoFocus={true}
4546
/>
4647
<button className="signin__button" type="submit">
4748
<div className="signin__button__label">Submit</div>

packages/root-password-protect/src/plugin/plugin.tsx

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import {Request, Response, NextFunction, Plugin, Server} from '@blinkk/root';
22
import bodyParser from 'body-parser';
33
import micromatch from 'micromatch';
44
import {renderToString} from 'preact-render-to-string';
5-
import {
6-
PasswordPage,
7-
PasswordPageProps,
8-
} from '../components/PasswordPage/PasswordPage.js';
5+
import {PasswordPage} from '../components/PasswordPage/PasswordPage.js';
96
import {generateNonce, setSecurityHeaders} from '../core/csp.js';
107
import {hashPassword, verifyPassword} from '../core/password.js';
118

129
const SESSION_COOKIE_HASH = 'password_protect.hash';
1310
const SESSION_COOKIE_SALT = 'password_protect.salt';
1411

12+
export enum PasswordProtectError {
13+
UNKNOWN = 'UNKNOWN',
14+
PASSWORD_REQUIRED = 'PASSWORD_REQUIRED',
15+
PASSWORD_INVALID = 'PASSWORD_INVALID',
16+
}
17+
1518
export interface PasswordProtectedRoute {
1619
/**
1720
* URL glob pattern to match (regex not supported yet).
@@ -107,8 +110,9 @@ async function handleProtectedRoute(
107110
!req.body.password
108111
) {
109112
res.status(400);
110-
renderPasswordPage(req, res, {error: 'Bad request (no password).'});
111-
return;
113+
return renderPasswordPage(req, res, {
114+
error: PasswordProtectError.PASSWORD_REQUIRED,
115+
});
112116
}
113117

114118
const password = req.body.password as string;
@@ -118,24 +122,23 @@ async function handleProtectedRoute(
118122
protectedRoute.password.salt
119123
);
120124
if (!isValid) {
121-
renderPasswordPage(req, res, {error: 'Incorrect password.'});
122-
return;
125+
return renderPasswordPage(req, res, {
126+
error: PasswordProtectError.PASSWORD_INVALID,
127+
});
123128
}
124129
// Set a session cookie value to verify subsequent requests.
125130
await setSessionCookie(protectedRoute, res);
126-
next();
127-
return;
131+
return next();
128132
}
129133

130134
// If previously logged in, check the session cookie against the current
131135
// password hash.
132136
const isValid = await verifySessionCookie(protectedRoute, req);
133137
if (isValid) {
134-
next();
135-
return;
138+
return next();
136139
}
137140

138-
renderPasswordPage(req, res);
141+
return renderPasswordPage(req, res);
139142
}
140143

141144
/**
@@ -172,12 +175,51 @@ async function verifySessionCookie(
172175
export async function renderPasswordPage(
173176
req: Request,
174177
res: Response,
175-
props?: Omit<PasswordPageProps, 'nonce'>
178+
options?: {error?: PasswordProtectError}
176179
) {
177-
const nonce = generateNonce();
178-
const mainHtml = renderToString(<PasswordPage {...props} nonce={nonce} />);
179-
const html = `<!doctype html>\n${mainHtml}`;
180-
res.setHeader('content-type', 'text/html');
181-
setSecurityHeaders(res, nonce);
182-
res.send(html);
180+
// If there's no `/401` route, fall back to the default password page.
181+
const defaultHandler = () => {
182+
const nonce = generateNonce();
183+
res.setHeader('content-type', 'text/html');
184+
res.status(401);
185+
setSecurityHeaders(res, nonce);
186+
const errorMsg = getErrorMessage(options?.error);
187+
const mainHtml = renderToString(
188+
<PasswordPage error={errorMsg} nonce={nonce} />
189+
);
190+
const html = `<!doctype html>\n${mainHtml}`;
191+
res.send(html);
192+
};
193+
194+
// If the app defines `routes/401.tsx`, render that page.
195+
if (req.renderer) {
196+
const [route, routeParams] = req.renderer.getRoute('/401');
197+
if (route) {
198+
// Add the error type to the req object. Routes that define a `handle()`
199+
// method should be able to read from it to construct an appropriate
200+
// error message for the page.
201+
(req as any).passwordProtect = {error: options?.error};
202+
return req.renderer.handleRoute(req, res, defaultHandler, route, {
203+
defaultStatusCode: 401,
204+
routeParams: routeParams,
205+
});
206+
}
207+
}
208+
return defaultHandler();
209+
}
210+
211+
/**
212+
* Converts a `PasswordProtectError` to a user-friendly error message.
213+
*/
214+
function getErrorMessage(error?: PasswordProtectError) {
215+
if (!error) {
216+
return undefined;
217+
}
218+
if (error === PasswordProtectError.PASSWORD_REQUIRED) {
219+
return 'Bad request (no password).';
220+
}
221+
if (error === PasswordProtectError.PASSWORD_INVALID) {
222+
return 'Incorrect password.';
223+
}
224+
return 'Unknown error.';
183225
}

packages/root/src/core/types.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,9 @@ export interface StaticContentResult {
174174
* that function is passed to `getStaticContent()`. Otherwise a default props
175175
* value is passed which includes the `rootConfig` and route param values.
176176
*/
177-
export type GetStaticContent = (props: any) =>
178-
| Promise<StaticContentResult | string>
179-
| StaticContentResult
180-
| string;
177+
export type GetStaticContent = (
178+
props: any
179+
) => Promise<StaticContentResult | string> | StaticContentResult | string;
181180

182181
export interface RouteModule {
183182
default?: ComponentType<unknown>;

0 commit comments

Comments
 (0)