mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(experience): add struct guard to session storage hook (#4933)
* feat(experience): add struct guard to session storage hook add struct guard to the session storage hook * test(experience): add unit test for the hook add unit test for the session storages hook
This commit is contained in:
parent
21af052321
commit
fbf545ecd4
5 changed files with 130 additions and 31 deletions
|
@ -45,6 +45,7 @@
|
|||
"@swc/core": "^1.3.52",
|
||||
"@swc/jest": "^0.2.26",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/react": "^18.0.31",
|
||||
|
|
39
packages/experience/src/hooks/use-session-storages.test.ts
Normal file
39
packages/experience/src/hooks/use-session-storages.test.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { mockSsoConnectors } from '@/__mocks__/logto';
|
||||
|
||||
import useSessionStorage, { StorageKeys } from './use-session-storages';
|
||||
|
||||
describe('useSessionStorage', () => {
|
||||
it('should set and get a email value', () => {
|
||||
const email = 'foo@test.io';
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { get, set, remove } = result.current;
|
||||
|
||||
expect(get(StorageKeys.SsoEmail)).toBeUndefined();
|
||||
set(StorageKeys.SsoEmail, email);
|
||||
expect(get(StorageKeys.SsoEmail)).toBe(email);
|
||||
remove(StorageKeys.SsoEmail);
|
||||
expect(get(StorageKeys.SsoEmail)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set and get a sso connectors value', () => {
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { get, set, remove } = result.current;
|
||||
|
||||
expect(get(StorageKeys.SsoConnectors)).toBeUndefined();
|
||||
set(StorageKeys.SsoConnectors, mockSsoConnectors);
|
||||
expect(get(StorageKeys.SsoConnectors)).toStrictEqual(mockSsoConnectors);
|
||||
remove(StorageKeys.SsoConnectors);
|
||||
expect(get(StorageKeys.SsoConnectors)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if the value is invalid', () => {
|
||||
const { result } = renderHook(() => useSessionStorage());
|
||||
const { get, set } = result.current;
|
||||
|
||||
// @ts-expect-error -- we are testing invalid values
|
||||
set(StorageKeys.SsoConnectors, 'foo');
|
||||
expect(get(StorageKeys.SsoEmail)).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* Provides a hook to access the session storage.
|
||||
*/
|
||||
import { type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { useCallback } from 'react';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { ssoConnectorMetadataGuard } from '@/types/guard';
|
||||
|
||||
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
||||
|
||||
|
@ -12,19 +13,16 @@ export enum StorageKeys {
|
|||
SsoConnectors = 'sso-connectors',
|
||||
}
|
||||
|
||||
const valueType = Object.freeze({
|
||||
[StorageKeys.SsoEmail]: 'string',
|
||||
[StorageKeys.SsoConnectors]: 'object',
|
||||
} satisfies { [key in StorageKeys]: 'string' | 'object' });
|
||||
const valueGuard = Object.freeze({
|
||||
[StorageKeys.SsoEmail]: s.string(),
|
||||
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
|
||||
} satisfies { [key in StorageKeys]: s.Struct<any> });
|
||||
|
||||
type StorageValue<K extends StorageKeys> = K extends StorageKeys.SsoEmail
|
||||
? string
|
||||
: K extends StorageKeys.SsoConnectors
|
||||
? SsoConnectorMetadata[]
|
||||
: never;
|
||||
type StorageValueType<K extends StorageKeys> = s.Infer<(typeof valueGuard)[K]>;
|
||||
|
||||
const useSessionStorage = () => {
|
||||
const set = useCallback(<T extends StorageKeys>(key: T, value: StorageValue<T>) => {
|
||||
const set = useCallback(<T extends StorageKeys>(key: T, value: StorageValueType<T>) => {
|
||||
if (typeof value === 'object') {
|
||||
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value));
|
||||
return;
|
||||
|
@ -33,27 +31,39 @@ const useSessionStorage = () => {
|
|||
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value);
|
||||
}, []);
|
||||
|
||||
const get = useCallback(<T extends StorageKeys>(key: T): StorageValue<T> | undefined => {
|
||||
const remove = useCallback((key: StorageKeys) => {
|
||||
sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`);
|
||||
}, []);
|
||||
|
||||
const get = useCallback(
|
||||
<T extends StorageKeys>(key: T): StorageValueType<T> | undefined => {
|
||||
const value = sessionStorage.getItem(`${logtoStorageKeyPrefix}:${key}`);
|
||||
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (valueType[key] === 'object') {
|
||||
return trySafe(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
() => JSON.parse(value) as StorageValue<T>
|
||||
const [error, rawValue] = valueGuard[key].validate(
|
||||
(() => {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- we use superstruct to validate the value
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// Clear the invalid value
|
||||
remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return value as StorageValue<T>;
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((key: StorageKeys) => {
|
||||
sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`);
|
||||
}, []);
|
||||
return rawValue;
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
return { set, get, remove };
|
||||
};
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import { SignInIdentifier, MissingProfile, MfaFactor } from '@logto/schemas';
|
||||
import {
|
||||
SignInIdentifier,
|
||||
MissingProfile,
|
||||
MfaFactor,
|
||||
type SsoConnectorMetadata,
|
||||
} from '@logto/schemas';
|
||||
import * as s from 'superstruct';
|
||||
|
||||
import { UserFlow } from '.';
|
||||
|
@ -106,3 +111,11 @@ export const webAuthnStateGuard = s.assign(
|
|||
);
|
||||
|
||||
export type WebAuthnState = s.Infer<typeof webAuthnStateGuard>;
|
||||
|
||||
/* Single Sign On */
|
||||
export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.object({
|
||||
id: s.string(),
|
||||
logo: s.string(),
|
||||
darkLogo: s.optional(s.string()),
|
||||
connectorName: s.string(),
|
||||
});
|
||||
|
|
|
@ -3584,6 +3584,9 @@ importers:
|
|||
'@testing-library/react':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@testing-library/react-hooks':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(@types/react@18.0.31)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@types/color':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
|
@ -9617,6 +9620,29 @@ packages:
|
|||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@testing-library/react-hooks@8.0.1(@types/react@18.0.31)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.9.0 || ^17.0.0
|
||||
react: ^16.9.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.9.0 || ^17.0.0
|
||||
react-test-renderer: ^16.9.0 || ^17.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
react-test-renderer:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.21.0
|
||||
'@types/react': 18.0.31
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-error-boundary: 3.1.4(react@18.2.0)
|
||||
dev: true
|
||||
|
||||
/@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==}
|
||||
engines: {node: '>=14'}
|
||||
|
@ -18179,6 +18205,16 @@ packages:
|
|||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/react-error-boundary@3.1.4(react@18.2.0):
|
||||
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
peerDependencies:
|
||||
react: '>=16.13.1 || ^18.0.0'
|
||||
dependencies:
|
||||
'@babel/runtime': 7.21.0
|
||||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/react-error-overlay@6.0.9:
|
||||
resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue