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/core": "^1.3.52",
|
||||||
"@swc/jest": "^0.2.26",
|
"@swc/jest": "^0.2.26",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
"@types/jest": "^29.4.0",
|
"@types/jest": "^29.4.0",
|
||||||
"@types/react": "^18.0.31",
|
"@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.
|
* Provides a hook to access the session storage.
|
||||||
*/
|
*/
|
||||||
import { type SsoConnectorMetadata } from '@logto/schemas';
|
|
||||||
import { trySafe } from '@silverhand/essentials';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import * as s from 'superstruct';
|
||||||
|
|
||||||
|
import { ssoConnectorMetadataGuard } from '@/types/guard';
|
||||||
|
|
||||||
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
||||||
|
|
||||||
|
@ -12,19 +13,16 @@ export enum StorageKeys {
|
||||||
SsoConnectors = 'sso-connectors',
|
SsoConnectors = 'sso-connectors',
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueType = Object.freeze({
|
const valueGuard = Object.freeze({
|
||||||
[StorageKeys.SsoEmail]: 'string',
|
[StorageKeys.SsoEmail]: s.string(),
|
||||||
[StorageKeys.SsoConnectors]: 'object',
|
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
|
||||||
} satisfies { [key in StorageKeys]: 'string' | 'object' });
|
// 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
|
type StorageValueType<K extends StorageKeys> = s.Infer<(typeof valueGuard)[K]>;
|
||||||
? string
|
|
||||||
: K extends StorageKeys.SsoConnectors
|
|
||||||
? SsoConnectorMetadata[]
|
|
||||||
: never;
|
|
||||||
|
|
||||||
const useSessionStorage = () => {
|
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') {
|
if (typeof value === 'object') {
|
||||||
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value));
|
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, JSON.stringify(value));
|
||||||
return;
|
return;
|
||||||
|
@ -33,28 +31,40 @@ const useSessionStorage = () => {
|
||||||
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value);
|
sessionStorage.setItem(`${logtoStorageKeyPrefix}:${key}`, value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const get = useCallback(<T extends StorageKeys>(key: T): StorageValue<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
return value as StorageValue<T>;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const remove = useCallback((key: StorageKeys) => {
|
const remove = useCallback((key: StorageKeys) => {
|
||||||
sessionStorage.removeItem(`${logtoStorageKeyPrefix}:${key}`);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue;
|
||||||
|
},
|
||||||
|
[remove]
|
||||||
|
);
|
||||||
|
|
||||||
return { set, get, 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 * as s from 'superstruct';
|
||||||
|
|
||||||
import { UserFlow } from '.';
|
import { UserFlow } from '.';
|
||||||
|
@ -106,3 +111,11 @@ export const webAuthnStateGuard = s.assign(
|
||||||
);
|
);
|
||||||
|
|
||||||
export type WebAuthnState = s.Infer<typeof webAuthnStateGuard>;
|
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':
|
'@testing-library/react':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(react-dom@18.2.0)(react@18.2.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':
|
'@types/color':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
@ -9617,6 +9620,29 @@ packages:
|
||||||
pretty-format: 27.5.1
|
pretty-format: 27.5.1
|
||||||
dev: true
|
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):
|
/@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==}
|
resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
@ -18179,6 +18205,16 @@ packages:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: true
|
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:
|
/react-error-overlay@6.0.9:
|
||||||
resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
|
resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
Loading…
Reference in a new issue