From 96985feb72eaea7c7b9bd492b9e26e72c3868c7f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 8 Mar 2024 17:34:23 +0100 Subject: [PATCH] feat(oauth-provider): allow customizing branding of the login page --- packages/oauth-provider/package.json | 1 - .../oauth-provider/src/assets/app/app.tsx | 24 ------- .../src/assets/app/backend-data.ts | 42 ++++++----- .../{grant-accept.tsx => accept-page.tsx} | 16 ++--- ...unt-list.tsx => account-selector-page.tsx} | 13 ++-- .../{authorize.tsx => authorize-page.tsx} | 26 +++---- .../components/{error.tsx => error-page.tsx} | 13 ++-- .../{login-form.tsx => login-page.tsx} | 14 ++-- .../{layout.tsx => page-layout.tsx} | 4 +- ...selector.tsx => session-selector-page.tsx} | 10 +-- .../oauth-provider/src/assets/app/main.tsx | 38 +++++----- packages/oauth-provider/src/oauth-provider.ts | 11 +-- .../oauth-provider/src/output/branding.ts | 70 +++++++++++++++++++ .../src/output/send-authorize-page.ts | 14 ++-- .../src/output/send-error-page.ts | 18 +++-- packages/oauth-provider/tailwind.config.js | 3 + pnpm-lock.yaml | 12 ---- 17 files changed, 195 insertions(+), 134 deletions(-) delete mode 100644 packages/oauth-provider/src/assets/app/app.tsx rename packages/oauth-provider/src/assets/app/components/{grant-accept.tsx => accept-page.tsx} (83%) rename packages/oauth-provider/src/assets/app/components/{account-list.tsx => account-selector-page.tsx} (88%) rename packages/oauth-provider/src/assets/app/components/{authorize.tsx => authorize-page.tsx} (90%) rename packages/oauth-provider/src/assets/app/components/{error.tsx => error-page.tsx} (77%) rename packages/oauth-provider/src/assets/app/components/{login-form.tsx => login-page.tsx} (89%) rename packages/oauth-provider/src/assets/app/components/{layout.tsx => page-layout.tsx} (88%) rename packages/oauth-provider/src/assets/app/components/{session-selector.tsx => session-selector-page.tsx} (83%) create mode 100644 packages/oauth-provider/src/output/branding.ts diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index b2310552d7f..11b1a26450c 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -59,7 +59,6 @@ "postcss": "^8.4.33", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.13", "rollup": "^4.10.0", "rollup-plugin-postcss": "^4.0.2", "tailwindcss": "^3.4.1", diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx deleted file mode 100644 index b330d89d05b..00000000000 --- a/packages/oauth-provider/src/assets/app/app.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ErrorBoundary } from 'react-error-boundary' - -import type { BackendData } from './backend-data' -import { Authorize } from './components/authorize' -import { Error } from './components/error' - -function FallbackRender({ error, resetErrorBoundary }) { - return ( - - ) -} - -export function App(data: BackendData) { - return ( - - {'error' in data ? : } - - ) -} diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index ce6c280d616..4734b3a27be 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -1,21 +1,29 @@ import type { ClientMetadata, Session } from './types' -// This is injected by the backend in the HTML template -declare const __backendData: - | Readonly<{ - clientId: string - clientMetadata: Readonly - requestUri: string - csrfCookie: string - sessions: readonly Readonly[] - consentRequired: boolean - loginHint?: string - }> - | Readonly<{ - error: string - error_description: string - }> +export type BrandingData = { + logo?: string +} -export const backendData = __backendData +export type ErrorData = { + error: string + error_description: string +} -export type BackendData = typeof __backendData +export type AuthorizeData = { + clientId: string + clientMetadata: ClientMetadata + requestUri: string + csrfCookie: string + sessions: Session[] + consentRequired: boolean + loginHint?: string +} + +// These values are injected by the backend when it builds the +// page HTML. + +export const brandingData = window['__brandingData'] as BrandingData | undefined +export const errorData = window['__errorData'] as ErrorData | undefined +export const authorizeData = window['__authorizeData'] as + | AuthorizeData + | undefined diff --git a/packages/oauth-provider/src/assets/app/components/grant-accept.tsx b/packages/oauth-provider/src/assets/app/components/accept-page.tsx similarity index 83% rename from packages/oauth-provider/src/assets/app/components/grant-accept.tsx rename to packages/oauth-provider/src/assets/app/components/accept-page.tsx index 508aa5101b2..755ec1feedd 100644 --- a/packages/oauth-provider/src/assets/app/components/grant-accept.tsx +++ b/packages/oauth-provider/src/assets/app/components/accept-page.tsx @@ -1,7 +1,7 @@ import { Account, ClientMetadata } from '../types' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function GrantAccept({ +export function AcceptPage({ account, clientId, clientMetadata, @@ -20,7 +20,7 @@ export function GrantAccept({ const clientName = clientMetadata.client_name || clientUri || clientId return ( - @@ -40,7 +40,7 @@ export function GrantAccept({ )} -

+

{clientName}

@@ -66,7 +66,7 @@ export function GrantAccept({ @@ -77,7 +77,7 @@ export function GrantAccept({ @@ -85,12 +85,12 @@ export function GrantAccept({ -
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/account-list.tsx b/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx similarity index 88% rename from packages/oauth-provider/src/assets/app/components/account-list.tsx rename to packages/oauth-provider/src/assets/app/components/account-selector-page.tsx index 0f74a6c181b..938166bf35d 100644 --- a/packages/oauth-provider/src/assets/app/components/account-list.tsx +++ b/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx @@ -1,7 +1,7 @@ import { Account } from '../types' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function AccountList({ +export function AccountSelectorPage({ accounts, onAccount, another = undefined, @@ -14,7 +14,10 @@ export function AccountList({ onBack?: () => void }) { return ( - +

Sign in as...

    @@ -60,13 +63,13 @@ export function AccountList({
)} -
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/authorize.tsx b/packages/oauth-provider/src/assets/app/components/authorize-page.tsx similarity index 90% rename from packages/oauth-provider/src/assets/app/components/authorize.tsx rename to packages/oauth-provider/src/assets/app/components/authorize-page.tsx index c291ef77a1b..e6daddc7357 100644 --- a/packages/oauth-provider/src/assets/app/components/authorize.tsx +++ b/packages/oauth-provider/src/assets/app/components/authorize-page.tsx @@ -1,15 +1,15 @@ import { useMemo, useState } from 'react' -import type { BackendData } from '../backend-data' +import type { AuthorizeData } from '../backend-data' import { cookies } from '../cookies' import { Account, Session } from '../types' -import { GrantAccept } from './grant-accept' -import { Layout } from './layout' -import { LoginForm } from './login-form' -import { SessionSelector } from './session-selector' +import { AcceptPage } from './accept-page' +import { PageLayout } from './page-layout' +import { LoginPage } from './login-page' +import { SessionSelectorPage } from './session-selector-page' -export function Authorize({ +export function AuthorizePage({ requestUri, clientId, clientMetadata, @@ -17,7 +17,7 @@ export function Authorize({ consentRequired: initialConsentRequired, loginHint: initialLoginHint, sessions: initialSessions, -}: Exclude) { +}: AuthorizeData) { const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) const [isDone, setIsDone] = useState(false) const [loginHint, setLoginHint] = useState(initialLoginHint) @@ -113,13 +113,15 @@ export function Authorize({ if (isDone) { // TODO - return You are being redirected + return ( + You are being redirected + ) } if (selectedSession) { if (selectedSession.loginRequired === false) { return ( - setSub(null)} onAccept={() => authorizeAccept(selectedSession.account)} onReject={() => authorizeReject()} @@ -130,7 +132,7 @@ export function Authorize({ ) } else { return ( - setLoginHint(undefined)} @@ -151,7 +153,7 @@ export function Authorize({ } return ( - diff --git a/packages/oauth-provider/src/assets/app/components/error.tsx b/packages/oauth-provider/src/assets/app/components/error-page.tsx similarity index 77% rename from packages/oauth-provider/src/assets/app/components/error.tsx rename to packages/oauth-provider/src/assets/app/components/error-page.tsx index f3c7c829ea8..87d72b6dde5 100644 --- a/packages/oauth-provider/src/assets/app/components/error.tsx +++ b/packages/oauth-provider/src/assets/app/components/error-page.tsx @@ -1,13 +1,10 @@ -import type { BackendData } from '../backend-data' +import type { ErrorData } from '../backend-data' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function Error({ - error, - error_description, -}: Extract) { +export function ErrorPage({ error, error_description }: ErrorData) { return ( - +
-
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/login-form.tsx b/packages/oauth-provider/src/assets/app/components/login-page.tsx similarity index 89% rename from packages/oauth-provider/src/assets/app/components/login-form.tsx rename to packages/oauth-provider/src/assets/app/components/login-page.tsx index f135353302d..59ca3ba7209 100644 --- a/packages/oauth-provider/src/assets/app/components/login-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/login-page.tsx @@ -1,8 +1,8 @@ import { FormHTMLAttributes } from 'react' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function LoginForm({ +export function LoginPage({ onLogin, onBack = undefined, username = '', @@ -37,7 +37,7 @@ export function LoginForm({ } return ( - +
@@ -77,7 +77,7 @@ export function LoginForm({ id="remember" name="remember" type="checkbox" - className="text-blue-600" + className="text-primary" /> @@ -93,7 +93,7 @@ export function LoginForm({
@@ -102,13 +102,13 @@ export function LoginForm({ )}
- + ) } diff --git a/packages/oauth-provider/src/assets/app/components/layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx similarity index 88% rename from packages/oauth-provider/src/assets/app/components/layout.tsx rename to packages/oauth-provider/src/assets/app/components/page-layout.tsx index e37dd9406ca..94c7cbe00c6 100644 --- a/packages/oauth-provider/src/assets/app/components/layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -1,6 +1,6 @@ import { HTMLAttributes } from 'react' -export function Layout({ +export function PageLayout({ title, subTitle, children, @@ -16,7 +16,7 @@ export function Layout({ {...props} >
-

+

{title}

{subTitle}

diff --git a/packages/oauth-provider/src/assets/app/components/session-selector.tsx b/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx similarity index 83% rename from packages/oauth-provider/src/assets/app/components/session-selector.tsx rename to packages/oauth-provider/src/assets/app/components/session-selector-page.tsx index 02db236c75e..43ba9609d44 100644 --- a/packages/oauth-provider/src/assets/app/components/session-selector.tsx +++ b/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' import { Session } from '../types' -import { AccountList } from './account-list' -import { LoginForm } from './login-form' +import { AccountSelectorPage } from './account-selector-page' +import { LoginPage } from './login-page' -export function SessionSelector({ +export function SessionSelectorPage({ sessions, onSession, onLogin, @@ -22,7 +22,7 @@ export function SessionSelector({ const [showLogin, setShowLogin] = useState(sessions.length === 0) return showLogin ? ( - 0 @@ -33,7 +33,7 @@ export function SessionSelector({ } /> ) : ( - s.account)} onAccount={(a) => { const session = sessions.find((s) => s.account.sub === a.sub) diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx index 248f3b864ed..6e7ad37c304 100644 --- a/packages/oauth-provider/src/assets/app/main.tsx +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -2,29 +2,29 @@ import './main.css' import { createRoot } from 'react-dom/client' -import { App } from './app' -import { backendData } from './backend-data' - -const url = new URL(window.location.href) +import { authorizeData, errorData } from './backend-data' +import { AuthorizePage } from './components/authorize-page' +import { ErrorPage } from './components/error-page' // When the user is logging in, make sure the page URL contains the // "request_uri" in case the user refreshes the page. -if ( - url.pathname === '/oauth/authorize' && - 'clientId' in backendData && - 'requestUri' in backendData -) { - if ( - !url.searchParams.has('client_id') && - !url.searchParams.has('request_uri') - ) { - url.search = '' - url.searchParams.set('client_id', backendData.clientId) - url.searchParams.set('request_uri', backendData.requestUri) - window.history.replaceState(history.state, '', url.pathname + url.search) - } +const url = new URL(window.location.href) +if (authorizeData && url.pathname === '/oauth/authorize') { + url.search = '' + url.searchParams.set('client_id', authorizeData.clientId) + url.searchParams.set('request_uri', authorizeData.requestUri) + window.history.replaceState(history.state, '', url.pathname + url.search) } +// TODO: inject brandingData (from backend-data.ts) into the page (logo & co) + const container = document.getElementById('root')! const root = createRoot(container) -root.render() + +if (authorizeData) { + root.render() +} else if (errorData) { + root.render() +} else { + throw new Error('No data found') +} diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index eab7c10856e..d94ac71490d 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -115,6 +115,7 @@ import { } from './token/types.js' import { VerifyTokenClaimsOptions } from './token/verify-token-claims.js' import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' +import { Branding } from './output/branding.js' export type OAuthProviderStore = Partial< ClientStore & @@ -854,10 +855,12 @@ export class OAuthProvider extends OAuthVerifier { Req extends IncomingMessage = IncomingMessage, Res extends ServerResponse = ServerResponse, >({ + branding, onError = process.env['NODE_ENV'] === 'development' ? (req, res, err): void => console.error('OAuthProvider error:', err) : undefined, }: { + branding?: Branding onError?: (req: Req, res: Res, err: unknown) => void }): Handler { const sessionManager = new SessionManager(this.sessionStore) @@ -1128,7 +1131,7 @@ export class OAuthProvider extends OAuthVerifier { } case 'authorize' in data: { await setupCsrfToken(req, res, csrfCookie(data.authorize.uri)) - return await sendAuthorizePage(req, res, data) + return await sendAuthorizePage(req, res, data, branding) } default: { // Should never happen @@ -1139,7 +1142,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) @@ -1235,7 +1238,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) @@ -1287,7 +1290,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts new file mode 100644 index 00000000000..1c38bc7d978 --- /dev/null +++ b/packages/oauth-provider/src/output/branding.ts @@ -0,0 +1,70 @@ +const DEFAULT_COLORS = { + primary: parseColor('#6231af')!, +} +export type BrandingColors = Record + +export type Branding = { + logo?: string + colors?: Partial +} + +export function buildBrandingData({ logo }: Branding = {}) { + return { + logo, + } +} + +const DEFAULT_COLOR_ENTRIES = Object.entries(DEFAULT_COLORS) +export function buildBrandingCss({ colors = {} }: Branding = {}) { + const vars = DEFAULT_COLOR_ENTRIES.map(([name, value]) => { + const color = Object.hasOwn(colors, name) ? colors[name] : undefined + const { r, g, b } = (color && parseColor(color)) || value + // alpha not supported by tailwind (it does not work that way) + return `--color-${name}: ${r} ${g} ${b};` + }) + return `:root { ${vars.join(' ')} }` +} + +function parseColor( + color: string, +): undefined | { r: number; g: number; b: number; a?: number } { + if (color.startsWith('#')) { + if (color.length === 4 || color.length === 5) { + const [r, g, b, a] = color + .slice(1) + .split('') + .map((c) => parseInt(`${c}${c}`, 16)) + return { r, g, b, a } + } + + if (color.length === 7 || color.length === 9) { + const r = parseInt(color.substr(1, 2), 16) + const g = parseInt(color.substr(3, 2), 16) + const b = parseInt(color.substr(5, 2), 16) + const a = + color.length === 9 ? parseInt(color.substr(7, 2), 16) : undefined + return { r, g, b, a } + } + + return undefined + } + + const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbMatch) { + const [, r, g, b] = rgbMatch + return { r: parseInt(r, 10), g: parseInt(g, 10), b: parseInt(b, 10) } + } + + const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbaMatch) { + const [, r, g, b, a] = rgbaMatch + return { + r: parseInt(r, 10), + g: parseInt(g, 10), + b: parseInt(b, 10), + a: parseInt(a, 10), + } + } + + return undefined +} diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index 676f96c5c41..7b89161382a 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -1,12 +1,13 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { html } from '@atproto/html' +import { Html, html } from '@atproto/html' import { Account } from '../account/account.js' import { getAsset } from '../assets/index.js' import { Client } from '../client/client.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { RequestUri } from '../request/request-uri.js' +import { Branding, buildBrandingCss, buildBrandingData } from './branding.js' import { declareBrowserGlobalVar, sendWebApp } from './send-web-app.js' export type AuthorizationResultAuthorize = { @@ -24,7 +25,7 @@ export type AuthorizationResultAuthorize = { } } -function buildBackendData(data: AuthorizationResultAuthorize) { +function buildAuthorizeData(data: AuthorizationResultAuthorize) { return { csrfCookie: `csrf-${data.authorize.uri}`, requestUri: data.authorize.uri, @@ -40,13 +41,18 @@ export async function sendAuthorizePage( req: IncomingMessage, res: ServerResponse, data: AuthorizationResultAuthorize, + branding?: Branding, ): Promise { return sendWebApp(req, res, { scripts: [ - declareBrowserGlobalVar('__backendData', buildBackendData(data)), + declareBrowserGlobalVar('__brandingData', buildBrandingData(branding)), + declareBrowserGlobalVar('__authorizeData', buildAuthorizeData(data)), await getAsset('main.js'), ], - styles: [await getAsset('main.css')], + styles: [ + await getAsset('main.css'), + Html.dangerouslyCreate([buildBrandingCss(branding)]), + ], title: 'Authorize', body: html`
`, }) diff --git a/packages/oauth-provider/src/output/send-error-page.ts b/packages/oauth-provider/src/output/send-error-page.ts index 6390938c18d..2ba023176e8 100644 --- a/packages/oauth-provider/src/output/send-error-page.ts +++ b/packages/oauth-provider/src/output/send-error-page.ts @@ -1,23 +1,29 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { html } from '@atproto/html' +import { Html, html } from '@atproto/html' -import { getAsset } from '../assets' -import { buildErrorPayload, buildErrorStatus } from './build-error-payload' -import { declareBrowserGlobalVar, sendWebApp } from './send-web-app' +import { getAsset } from '../assets/index.js' +import { Branding, buildBrandingCss, buildBrandingData } from './branding.js' +import { buildErrorPayload, buildErrorStatus } from './build-error-payload.js' +import { declareBrowserGlobalVar, sendWebApp } from './send-web-app.js' export async function sendErrorPage( req: IncomingMessage, res: ServerResponse, err: unknown, + branding?: Branding, ): Promise { return sendWebApp(req, res, { status: buildErrorStatus(err), scripts: [ - declareBrowserGlobalVar('__backendData', buildErrorPayload(err)), + declareBrowserGlobalVar('__brandingData', buildBrandingData(branding)), + declareBrowserGlobalVar('__errorData', buildErrorPayload(err)), await getAsset('main.js'), ], - styles: [await getAsset('main.css')], + styles: [ + await getAsset('main.css'), + Html.dangerouslyCreate([buildBrandingCss(branding)]), + ], title: 'Error', body: html`
`, }) diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js index 89eabe180e4..6a011d407b2 100644 --- a/packages/oauth-provider/tailwind.config.js +++ b/packages/oauth-provider/tailwind.config.js @@ -2,6 +2,9 @@ export default { content: ['src/assets/app/**/*.{js,ts,jsx,tsx}'], theme: { + colors: { + primary: 'rgb(var(--color-primary) / )', + }, extend: {}, }, plugins: [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5777f1700d..c0c8f3abb86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -765,9 +765,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) - react-error-boundary: - specifier: ^4.0.13 - version: 4.0.13(react@18.2.0) rollup: specifier: ^4.10.0 version: 4.12.0 @@ -12176,15 +12173,6 @@ packages: scheduler: 0.23.0 dev: true - /react-error-boundary@4.0.13(react@18.2.0): - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} - peerDependencies: - react: '>=16.13.1' - dependencies: - '@babel/runtime': 7.22.10 - react: 18.2.0 - dev: true - /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true