Compare commits

...

2 commits

Author SHA1 Message Date
e8efe9859b Typography - new small storybook section
All checks were successful
forgejo/Procyon/procyon/pipeline/pr-develop This commit looks good
forgejo/Procyon/procyon/pipeline/head This commit looks good
2023-11-02 22:25:12 +01:00
cef6fd45a9 Minor auth and api updates + rename Title component 2023-11-02 22:25:12 +01:00
23 changed files with 235 additions and 310 deletions

View file

@ -10,5 +10,5 @@ export const FetchLoader: FC<FetchLoaderProps> = ({ children, active, placeholde
if (!active) { if (!active) {
return <>{children}</>; return <>{children}</>;
} }
return <div className="animate-pulse flex-1 py-1">{placeholder ?? null}</div>; return <div className="animate-pulse flex-1 py-1">{placeholder}</div>;
}; };

View file

@ -20,7 +20,7 @@ export type BaseQueryFetchParams<Body> = {
url: string; url: string;
}; };
export type BaseQueryWithReauthParams = { export type BaseQueryWithRefreshParams = {
authentication: { authentication: {
baseUrl: string; baseUrl: string;
clientId: string; clientId: string;
@ -70,11 +70,11 @@ export const baseQuery =
} }
}; };
export const baseQueryWithReauth = export const baseQueryWithRefresh =
<Response = { data: unknown }, RequestBody = any>({ <Response = { data: unknown }, RequestBody = any>({
baseUrl, baseUrl,
authentication, authentication,
}: BaseQueryWithReauthParams): BaseQueryFn<BaseQueryFetchParams<RequestBody>, Response, FetchBaseQueryError> => }: BaseQueryWithRefreshParams): BaseQueryFn<BaseQueryFetchParams<RequestBody>, Response, FetchBaseQueryError> =>
async (args, api, extraOptions) => { async (args, api, extraOptions) => {
const authReducer: any = (api.getState() as AuthRootState).procyon[AUTH_REDUCER_NAME]; const authReducer: any = (api.getState() as AuthRootState).procyon[AUTH_REDUCER_NAME];
const accessTokenExpired = new Date(authReducer.accessExpiresTime) < new Date(); const accessTokenExpired = new Date(authReducer.accessExpiresTime) < new Date();

View file

@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'wouter';
import { setAuthTokens } from '@procyon/auth/slice';
import { fetchTokens } from '../actions';
import { UseAuthParams } from '../types';
export const useAuthCode = ({ baseUrl, clientId, tokenEndpoint, redirectUri }: UseAuthParams) => {
const dispatch = useDispatch<any>();
const [, setLocation] = useLocation();
return useCallback(async (code: string) => {
const { data: tokens } = await fetchTokens({
baseUrl,
tokenEndpoint,
clientId,
redirectUri: redirectUri,
grantType: 'authorization_code',
code,
});
if (tokens) {
dispatch(
setAuthTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
accessExpiresIn: tokens.expires_in,
refreshExpiresIn: tokens.refresh_expires_in,
sessionState: tokens.session_state,
})
);
setLocation('/');
}
}, []);
};

View file

@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { UseAuthParams } from '../types';
const generateState = () =>
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
export const useLogin = ({ baseUrl, clientId, authEndpoint, redirectUri }: UseAuthParams) => {
return useCallback(() => {
const state = generateState();
const urlParams: Record<string, string> = {
client_id: clientId,
redirect_uri: redirectUri,
response_mode: 'fragment',
response_type: 'code',
scope: 'openid profile',
state,
};
const connectionURI = new URL(`${baseUrl}${authEndpoint}`);
for (const key of Object.keys(urlParams)) {
connectionURI.searchParams.append(key, urlParams[key]);
}
window.location.assign(connectionURI);
}, []);
};

View file

@ -1,72 +0,0 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'wouter';
import { setAuthTokens } from '@procyon/auth/slice';
import { fetchTokens } from '../actions';
import { UseOAuthParams } from '../types';
const generateState = () =>
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
/**
* test
* @param param0 test
* @returns
*/
export const useOAuth = ({ baseUrl, clientId, authEndpoint, tokenEndpoint, redirectUri }: UseOAuthParams) => {
const dispatch = useDispatch<any>();
const [, setLocation] = useLocation();
const redirect = useCallback(() => {
const state = generateState();
const urlParams: Record<string, string> = {
client_id: clientId,
redirect_uri: redirectUri,
response_mode: 'fragment',
response_type: 'code',
scope: 'openid profile',
state,
};
const conectionURI = new URL(`${baseUrl}${authEndpoint}`);
for (const key of Object.keys(urlParams)) {
conectionURI.searchParams.append(key, urlParams[key]);
}
window.location.assign(conectionURI);
}, []);
const obtainTokens = useCallback(async (code: string) => {
const { data: tokens } = await fetchTokens({
baseUrl,
tokenEndpoint,
clientId,
redirectUri: redirectUri,
grantType: 'authorization_code',
code,
});
if (tokens) {
dispatch(
setAuthTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
accessExpiresIn: tokens.expires_in,
refreshExpiresIn: tokens.refresh_expires_in,
sessionState: tokens.session_state,
})
);
setLocation('/');
}
}, []);
return {
redirect,
obtainTokens,
};
};

View file

@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { addSeconds } from 'date-fns'; import { addSeconds } from 'date-fns';
import { AUTH_REDUCER_NAME, AuthAttributes, AuthRootState, AuthTokens, ModifyAuthAttribute } from './types'; import { AUTH_REDUCER_NAME, AuthAttributes, AuthRootState, AuthTokenPayload, ModifyAuthAttribute } from './types';
const initialState: AuthAttributes = { const initialState: AuthAttributes = {
authenticated: undefined, authenticated: undefined,
@ -19,7 +19,7 @@ const authSlice = createSlice({
name: AUTH_REDUCER_NAME, name: AUTH_REDUCER_NAME,
initialState, initialState,
reducers: { reducers: {
setAuthTokens: (_, action: PayloadAction<AuthTokens>) => { setAuthTokens: (_, action: PayloadAction<AuthTokenPayload>) => {
const { accessExpiresIn, refreshExpiresIn } = action.payload; const { accessExpiresIn, refreshExpiresIn } = action.payload;
return { return {
...action.payload, ...action.payload,

View file

@ -2,7 +2,7 @@ import { ROOT_REDUCER_NAME } from '@procyon/constants/redux';
export const AUTH_REDUCER_NAME = 'auth'; export const AUTH_REDUCER_NAME = 'auth';
export type AuthTokens = { export type AuthTokenPayload = {
accessExpiresIn?: number; accessExpiresIn?: number;
accessToken?: string; accessToken?: string;
idToken?: string; idToken?: string;
@ -16,7 +16,7 @@ export type AuthAttributes<CustomAttributes = unknown> = {
authenticated?: boolean; authenticated?: boolean;
refreshExpiresTime?: string; refreshExpiresTime?: string;
} & CustomAttributes & } & CustomAttributes &
AuthTokens; AuthTokenPayload;
export type ModifyAuthAttribute<P = unknown> = { export type ModifyAuthAttribute<P = unknown> = {
key: keyof AuthAttributes<P>; key: keyof AuthAttributes<P>;
@ -29,7 +29,7 @@ export type AuthRootState = {
}; };
}; };
export type UseOAuthParams = { export type UseAuthParams = {
authEndpoint: string; authEndpoint: string;
baseUrl: string; baseUrl: string;
clientId: string; clientId: string;
@ -38,7 +38,7 @@ export type UseOAuthParams = {
tokenEndpoint: string; tokenEndpoint: string;
}; };
export type FetchTokensParams = Pick<UseOAuthParams, 'baseUrl' | 'tokenEndpoint' | 'clientId'> & { export type FetchTokensParams = Pick<UseAuthParams, 'baseUrl' | 'tokenEndpoint' | 'clientId'> & {
code: string; code: string;
grantType: 'authorization_code'; grantType: 'authorization_code';
redirectUri: string; redirectUri: string;

View file

@ -2,9 +2,10 @@ import React, { FC, Fragment, useEffect, useMemo, useState } from 'react';
import { range } from 'ramda'; import { range } from 'ramda';
import { add, endOfMonth, endOfWeek, getWeeksInMonth, startOfMonth, startOfWeek, sub } from 'date-fns'; import { add, endOfMonth, endOfWeek, getWeeksInMonth, startOfMonth, startOfWeek, sub } from 'date-fns';
import { SizeEnum } from '@procyon/types/common';
import { isNilOrEmpty } from '@procyon/utils'; import { isNilOrEmpty } from '@procyon/utils';
import { Title, TitleSizeEnum } from '../../Title'; import { Text } from '../../Text';
import { CalendarCell, CalendarCellProps } from './CalendarCell'; import { CalendarCell, CalendarCellProps } from './CalendarCell';
import { CalendarDays, CalendarDaysProps } from './CalendarDays'; import { CalendarDays, CalendarDaysProps } from './CalendarDays';
import { Controller, ControllerProps } from './Controller'; import { Controller, ControllerProps } from './Controller';
@ -181,7 +182,7 @@ export function Calendar({
return ( return (
<> <>
{!isNilOrEmpty(title) && <Title size={TitleSizeEnum.md}>{title}</Title>} {!isNilOrEmpty(title) && <Text size={SizeEnum.md}>{title}</Text>}
<Control <Control
layout={layout} layout={layout}
onNextClick={handleClickNext} onNextClick={handleClickNext}

View file

@ -1,25 +1,14 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import clsx from 'clsx';
import { DirectionEnum } from '@procyon/types/common';
export type SectionProps = { export type SectionProps = {
Title: FC; Title: FC;
children: any; children: any;
space?: Partial<Record<DirectionEnum, number>>; className?: string;
}; };
const Section: FC<SectionProps> = ({ Title, children, space = { [DirectionEnum.bottom]: 4 } }) => { export const Section: FC<SectionProps> = ({ Title, children, className }) => (
const spaces: string[] = []; <div className={className}>
for (const direct in space) {
spaces.push(`m${direct}-${space[direct]}`);
}
return (
<div className={clsx(spaces)}>
{Title && <Title />} {Title && <Title />}
{children} {children}
</div> </div>
); )
};
export default Section;

View file

@ -0,0 +1,25 @@
import React, { FC, ReactElement } from 'react';
import clsx from 'clsx';
import { SizeEnum } from '@procyon/types/common';
export type TextProps = {
children?: ReactElement | string;
className?: string;
size?: SizeEnum;
};
export const Text: FC<TextProps> = ({ size = SizeEnum.sm, children, className }) => (
<div
className={clsx(
{
'text-base': size === SizeEnum.sm,
'text-2xl mb-1': size === SizeEnum.md,
'text-3xl mb-2': size === SizeEnum.lg,
'text-4xl mb-3': size === SizeEnum.xl,
},
className
)}>
{children}
</div>
)

View file

@ -1,39 +0,0 @@
import React, { FC, ReactElement } from 'react';
import clsx from 'clsx';
import { DirectionEnum, SizeEnum } from '@procyon/types/common';
export enum TitleSizeEnum {
lg = 'lg',
md = 'md',
xl = 'xl',
}
export type TitleProps = {
children?: ReactElement | string;
className?: string;
margin?: Partial<Record<DirectionEnum, number>>;
size?: TitleSizeEnum;
};
export const Title: FC<TitleProps> = ({ size = SizeEnum.sm, children, className, margin }) => {
const margins: string[] = [];
for (const direct in margin) {
margins.push(`m${direct}-${margin[direct]}`);
}
return (
<div
className={clsx(
{
'text-base': size === SizeEnum.sm,
'text-2xl mb-1': size === SizeEnum.md,
'text-3xl mb-2': size === SizeEnum.lg,
'text-4xl mb-3': size === SizeEnum.xl,
},
margins,
className
)}>
{children}
</div>
);
};

View file

@ -1,3 +0,0 @@
export const EMPTY_OBJECT = {};
export const EMPTY_ARRAY: [] = [];

View file

@ -43,13 +43,11 @@ module.exports = config;
<Unstyled> <Unstyled>
<Message status={StatusEnum.info}> <Message status={StatusEnum.info}>
If you want to use automatical reobtain access token, do not forget to use `baseQueryWithReauth` instead of If you want to use automatically extend access token, use `baseQueryWithRefresh` instead of
`baseQuery` from `@procyon/api/query`. Pass same parameters as to `useOauth` hook. `baseQuery` from `@procyon/api/query`.
</Message> </Message>
</Unstyled> </Unstyled>
<br />
```ts ```ts
import { createApi } from '@reduxjs/toolkit/query/react'; import { createApi } from '@reduxjs/toolkit/query/react';

View file

@ -1,7 +1,4 @@
import { Canvas, Meta, Source, Unstyled } from '@storybook/blocks'; import { Canvas, Meta, Source } from '@storybook/blocks';
import Message from '@procyon/components/Message';
import { StatusEnum } from '@procyon/types/common';
import apiContent from './helpers/api?raw'; import apiContent from './helpers/api?raw';
import { FetchComponent } from './helpers/FetchComponent'; import { FetchComponent } from './helpers/FetchComponent';
@ -16,15 +13,6 @@ import reduxContent from './helpers/redux?raw';
`@procyon/api` module for fetch. `@procyon/api` module for fetch.
<Unstyled>
<Message status={StatusEnum.info}>
If you want to use automatical reobtain access token, do not forget to use `baseQueryWithReauth` instead of
`baseQuery` from `@procyon/api/query`. Pass same parameters as to `useOauth` hook.
</Message>
</Unstyled>
<br />
## Preparation ## Preparation
### Define API via RTQuery ### Define API via RTQuery

View file

@ -4,6 +4,7 @@ import Message from '@procyon/components/Message';
import { StatusEnum } from '@procyon/types/common'; import { StatusEnum } from '@procyon/types/common';
import storeCode from './helpers/redux?raw'; import storeCode from './helpers/redux?raw';
import hookCode from './helpers/hook?raw';
<Meta title="Auth/Introduction" /> <Meta title="Auth/Introduction" />
@ -11,44 +12,29 @@ import storeCode from './helpers/redux?raw';
`@procyon/auth` module for authenticated. `@procyon/auth` module for authenticated.
For work with IdP (Identity Provider server) use `useOAuth` hook. For work with IdP (Identity Provider) server use `useLogin` and `useAuthCode` hooks.
<Unstyled> <Unstyled>
<Message status={StatusEnum.info}> <Message status={StatusEnum.info}>
After reload page, reducer data are synchronized with browser session storage. So they are saved until close browser When refresh page, data are synchronized with browser session storage.
tab.
</Message> </Message>
</Unstyled> </Unstyled>
<br /> <br />
<Unstyled> <Unstyled>
<Message status={StatusEnum.error}> <Message status={StatusEnum.info}>
If you want to use automatical reobtain access token, do not forget to use `baseQueryWithReauth` instead of If you want to use automatically extend access token, use `baseQueryWithRefresh` instead of
`baseQuery` from `@procyon/api/query` and pass same parameters as to `useOauth` hook. `baseQuery` from `@procyon/api/query`.
</Message> </Message>
</Unstyled> </Unstyled>
<br /> <br />
## Redux store ### Redux storage
Redux is used as storage. `@procyon/api` use this storage for pickup access token for fetch auth header.
<Unstyled>
<Message status={StatusEnum.warning}>
Is important to to use auth reducer under <b>procyon</b> main reducer.
</Message>
</Unstyled>
<br />
### example
<Source language="ts" code={storeCode} /> <Source language="ts" code={storeCode} />
### stored data
<PureArgsTable <PureArgsTable
rows={{ rows={{
authenticated: { authenticated: {
@ -86,3 +72,7 @@ Redux is used as storage. `@procyon/api` use this storage for pickup access toke
}, },
}} }}
/> />
### Sample source
<Source language="ts" code={hookCode} />

View file

@ -1,60 +0,0 @@
import { Meta, PureArgsTable, Source } from '@storybook/blocks';
import hookCode from './helpers/hook?raw';
<Meta title="Auth/useOAuth" />
# `useOAuth` hook
`import { useOAuth } from @procyon/auth/hook/useOAuth`
Hook will communicate with IdP server like a Keycloak.
<Source language="ts" code={hookCode} />
### Params
<PureArgsTable
rows={{
baseUrl: {
description: 'Base Identity Provider URL.',
name: 'baseUrl',
},
clientId: {
description: 'Client ID defined in IdP',
name: 'clientId',
},
authEndpoint: {
description: 'URI for redirect to IdP login form',
name: 'authEndpoint',
},
tokenEndpoint: {
description: 'Endpoint whitch is called for obtain tokends',
name: 'tokenEndpoint',
},
logoutEndpoint: {
description: 'Endpoint for revoke session',
name: 'logoutEndpoint',
},
redirectUrl: {
description: 'URL to redirect from IdP back to application',
name: 'redirectUrl',
},
}}
/>
### Returns
<PureArgsTable
rows={{
redirect: {
description: 'Function. When call, make redirecto to IdP login page',
name: 'redirect',
},
obtainTokens: {
description:
'Function. When return from IdP, app will obtain auth code which allow to fetch access tokens. After obtain tokens, make redirect to root route (/).',
name: 'obtainTokens',
},
}}
/>

View file

@ -1,32 +1,36 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useLocation } from 'wouter'; import { useLocation } from 'wouter';
import { useOAuth } from '@procyon/auth/hook/useOAuth'; import { useAuthCode } from '@procyon/auth/hook/useAuthCode';
import { useLogin } from '@procyon/auth/hook/useLogin';
const authConfig = {
baseUrl: 'http://keycloak.local',
clientId: 'client_id',
tokenEndpoint: '/realm/localdev/token',
authEndpoint: '/realm/localdev/auth',
redirectUri: 'http://localhost:3330/login/callback',
logoutEndpoint: '/realm/localdev/logout',
};
export const Component: FC = () => { export const Component: FC = () => {
const [location] = useLocation(); const [location] = useLocation();
const { redirect, obtainTokens } = useOAuth({ const doLogin = useLogin(authConfig);
baseUrl: 'http://keycloak.local', const changeCodeForTokens = useAuthCode(authConfig);
clientId: 'client_id',
tokenEndpoint: '/realm/localdev/token',
authEndpoint: '/realm/localdev/auth',
redirectUri: 'http://localhost:3330/login/callback',
logoutEndpoint: '/realm/localdev/logout',
});
if (location === '/login/callback') { if (location === '/login/callback') {
const params = new URLSearchParams(window.location.href); const params = new URLSearchParams(window.location.href);
const code = params.get('code') ?? null; const code = params.get('code') ?? null;
if (code) { if (code) {
obtainTokens(code); changeCodeForTokens(code);
} }
return null; return null;
} }
return ( return (
<> <>
<button onClick={redirect}>login</button> <button onClick={doLogin}>login</button>
</> </>
); );
}; };

View file

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import Section from '@procyon/components/Section'; import { Section } from '@procyon/components/Section';
import { Title, TitleSizeEnum } from '@procyon/components/Title'; import { Text } from '@procyon/components/Text';
import { DirectionEnum } from '@procyon/types/common'; import { SizeEnum } from '@procyon/types/common';
type Story = StoryObj<typeof Section>; type Story = StoryObj<typeof Section>;
@ -21,7 +21,7 @@ export default {
export const Default: Story = { export const Default: Story = {
args: { args: {
Title: () => <Title>Section</Title>, Title: () => <Text>Section</Text>,
children: 'Content', children: 'Content',
}, },
}; };
@ -34,24 +34,21 @@ export const WithoutTitle: Story = {
export const Medium: Story = { export const Medium: Story = {
args: { args: {
Title: () => <Title size={TitleSizeEnum.md}>Section</Title>, Title: () => <Text size={SizeEnum.md}>Section</Text>,
children: 'content', children: 'content',
space: { [DirectionEnum.y]: 2 },
}, },
}; };
export const Large: Story = { export const Large: Story = {
args: { args: {
Title: () => <Title size={TitleSizeEnum.lg}>Section</Title>, Title: () => <Text size={SizeEnum.lg}>Section</Text>,
children: 'content', children: 'content',
space: { [DirectionEnum.y]: 2, [DirectionEnum.top]: 3 },
}, },
}; };
export const ExtraLarge: Story = { export const ExtraLarge: Story = {
args: { args: {
Title: () => <Title size={TitleSizeEnum.xl}>Section</Title>, Title: () => <Text size={SizeEnum.xl}>Section</Text>,
children: 'content', children: 'content',
space: { [DirectionEnum.y]: 2, [DirectionEnum.top]: 4 },
}, },
}; };

View file

@ -0,0 +1,61 @@
import React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Text } from '@procyon/components/Text';
import { SizeEnum } from '@procyon/types/common';
type Story = StoryObj<typeof Text>;
export default {
component: Text,
decorators: [
(Story) => (
<>
<Story />
content
</>
),
],
parameters: {
docs: {
description: {
component: "`import { Text } from '@procyon/components/Text';`",
},
},
},
tags: ['autodocs'],
} as Meta;
export const Default: Story = {
args: {
children: 'Default Text',
},
};
export const Standard: Story = {
args: {
children: 'Head title',
size: SizeEnum.sm,
},
};
export const Middle: Story = {
args: {
children: 'Head title',
size: SizeEnum.md,
},
};
export const Large: Story = {
args: {
children: 'Head title',
size: SizeEnum.lg,
},
};
export const ExtraLarge: Story = {
args: {
children: 'Head title',
size: SizeEnum.xl,
},
};

View file

@ -1,48 +0,0 @@
import React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Title, TitleSizeEnum } from '@procyon/components/Title';
import { DirectionEnum } from '@procyon/types/common';
type Story = StoryObj<typeof Title>;
export default {
component: Title,
decorators: [
(Story) => (
<>
<Story />
content
</>
),
],
parameters: {
docs: {
description: {
component: "`import { Title } from '@procyon/components/Title';`",
},
},
},
tags: ['autodocs'],
} as Meta;
export const Default: Story = {
args: {
children: 'Default Title',
},
};
export const Header: Story = {
args: {
children: 'Head title',
size: TitleSizeEnum.md,
},
};
export const HeaderWithSpace: Story = {
args: {
children: 'Head title',
size: TitleSizeEnum.md,
margin: { [DirectionEnum.bottom]: 4 },
},
};

View file

@ -49,7 +49,7 @@ export const Hook: Story = {
onClick={() => onClick={() =>
showToaster({ showToaster({
id: 'toaster1', id: 'toaster1',
title: 'Title', title: 'Text',
message: 'Message', message: 'Message',
status: StatusEnum.info, status: StatusEnum.info,
}) })
@ -76,7 +76,7 @@ export const OutsideReact: Story = {
onClick={() => onClick={() =>
showToaster({ showToaster({
id: 'toaster2', id: 'toaster2',
title: 'Title', title: 'Text',
message: 'Message', message: 'Message',
status: StatusEnum.success, status: StatusEnum.success,
}) })

View file

@ -0,0 +1,27 @@
import { Meta } from '@storybook/blocks';
<Meta title="Typography/Introduction" />
# Typography
## Sizes
Component for show text base on size is `Text` from `import { Text } from '@procyon/components/Text';`
### Small (sm)
- Standard font size for all page objects
### Medium (md)
- Section title
- Multiple times per page
### Large (lg)
- Page title
- Only once
### Extra Large (xl)
- For single page sections, like login or registration