diff --git a/packages/shared/scss/normalized.scss b/packages/shared/scss/normalized.scss index 0bcb1ca..3ab8f51 100644 --- a/packages/shared/scss/normalized.scss +++ b/packages/shared/scss/normalized.scss @@ -42,6 +42,10 @@ form { gap: 20px; border-radius: 6px; background-color: var(--color-static-white); + + &.hidden { + display: none; + } } .input-field { diff --git a/packages/sign-in-with-verification-code/src/index.ts b/packages/sign-in-with-verification-code/src/index.ts index 78b0354..38688a4 100644 --- a/packages/sign-in-with-verification-code/src/index.ts +++ b/packages/sign-in-with-verification-code/src/index.ts @@ -123,6 +123,7 @@ window.addEventListener('load', () => { window.location.replace(redirectTo); } catch (error) { handleError(error); + setSubmitLoading(false); } }); }); diff --git a/packages/sign-up-with-verification-code/src/index.ts b/packages/sign-up-with-verification-code/src/index.ts index 2bb8e3d..eaebd37 100644 --- a/packages/sign-up-with-verification-code/src/index.ts +++ b/packages/sign-up-with-verification-code/src/index.ts @@ -122,6 +122,7 @@ window.addEventListener('load', () => { window.location.replace(redirectTo); } catch (error) { handleError(error); + setSubmitLoading(false); } }); }); diff --git a/packages/social-sign-in-and-register/.eslintrc.cjs b/packages/social-sign-in-and-register/.eslintrc.cjs new file mode 100644 index 0000000..57e7f69 --- /dev/null +++ b/packages/social-sign-in-and-register/.eslintrc.cjs @@ -0,0 +1,25 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: '@silverhand/eslint-config', + rules: { + 'jsx-a11y/no-autofocus': 'off', + 'unicorn/prefer-string-replace-all': 'off', + 'no-restricted-syntax': 'off', + '@silverhand/fp/no-mutation': 'off', + '@silverhand/fp/no-let': 'off', + }, + overrides: [ + { + files: ['*.config.js', '*.config.ts', '*.d.ts'], + rules: { + 'import/no-unused-modules': 'off', + }, + }, + { + files: ['*.d.ts'], + rules: { + 'import/no-unassigned-import': 'off', + }, + }, + ], +}; diff --git a/packages/social-sign-in-and-register/favicon.ico b/packages/social-sign-in-and-register/favicon.ico new file mode 100644 index 0000000..3cf672d Binary files /dev/null and b/packages/social-sign-in-and-register/favicon.ico differ diff --git a/packages/social-sign-in-and-register/index.html b/packages/social-sign-in-and-register/index.html new file mode 100644 index 0000000..20b4130 --- /dev/null +++ b/packages/social-sign-in-and-register/index.html @@ -0,0 +1,24 @@ + + + + + + Logto experience sample + + + + + + +
+
+ + +
+ +
+ + diff --git a/packages/social-sign-in-and-register/package.json b/packages/social-sign-in-and-register/package.json new file mode 100644 index 0000000..faaee9a --- /dev/null +++ b/packages/social-sign-in-and-register/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logto/experience-sample-social", + "description": "A sample project demonstrates how to use Logto Experience API to build a social sign-in page.", + "author": "Silverhand Inc. ", + "license": "MIT", + "version": "0.0.0", + "type": "module", + "scripts": { + "precommit": "lint-staged", + "start": "vite", + "dev": "logto-tunnel --verbose & vite", + "build": "tsc -b && vite build", + "lint": "eslint --ext .ts src", + "preview": "vite preview" + }, + "devDependencies": { + "@logto/experience-sample-shared": "workspace:^", + "@logto/schemas": "^1.19.0", + "@silverhand/eslint-config": "^6.0.1", + "@silverhand/ts-config": "^6.0.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.0", + "prettier": "^3.0.0", + "stylelint": "^15.0.0", + "typescript": "^5.5.3", + "vite": "^5.4.6" + }, + "stylelint": { + "extends": "@silverhand/eslint-config/.stylelintrc" + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "dependencies": { + "js-base64": "^3.7.7", + "superstruct": "^2.0.2" + } +} diff --git a/packages/social-sign-in-and-register/src/consts.ts b/packages/social-sign-in-and-register/src/consts.ts new file mode 100644 index 0000000..9b623ef --- /dev/null +++ b/packages/social-sign-in-and-register/src/consts.ts @@ -0,0 +1,13 @@ +import { object, union, literal, string } from 'superstruct'; + +/** + * Your social connector ID, which can be found in connector details page. + */ +export const connectorId = 'abcdefghijklmnopqrstu'; + +export const socialAccountNotExistErrorDataGuard = object({ + relatedUser: object({ + type: union([literal('email'), literal('phone')]), + value: string(), + }), +}); diff --git a/packages/social-sign-in-and-register/src/include.d/dom.d.ts b/packages/social-sign-in-and-register/src/include.d/dom.d.ts new file mode 100644 index 0000000..36ac3e0 --- /dev/null +++ b/packages/social-sign-in-and-register/src/include.d/dom.d.ts @@ -0,0 +1,3 @@ +interface Body { + json(): Promise; +} diff --git a/packages/social-sign-in-and-register/src/include.d/vite-env.d.ts b/packages/social-sign-in-and-register/src/include.d/vite-env.d.ts new file mode 100644 index 0000000..e058f21 --- /dev/null +++ b/packages/social-sign-in-and-register/src/include.d/vite-env.d.ts @@ -0,0 +1 @@ +import 'vite/client'; diff --git a/packages/social-sign-in-and-register/src/index.ts b/packages/social-sign-in-and-register/src/index.ts new file mode 100644 index 0000000..9945d93 --- /dev/null +++ b/packages/social-sign-in-and-register/src/index.ts @@ -0,0 +1,97 @@ +import { Api } from '@logto/experience-sample-shared/api'; +import { clearError, handleError, setSubmitLoading } from '@logto/experience-sample-shared/utils'; +import { InteractionEvent, type RequestErrorBody } from '@logto/schemas'; +import { validate } from 'superstruct'; + +import '@logto/experience-sample-shared/scss/normalized.scss'; +import { connectorId, socialAccountNotExistErrorDataGuard } from './consts.js'; +import { generateRandomString, parseQueryParameters } from './utils.js'; + +const api = new Api({ baseUrl: window.location.origin }); + +window.addEventListener('load', async () => { + if (window.location.pathname.startsWith(`/callback/${connectorId}`)) { + void handleSocialCallback(); + } else { + document.querySelector('form')?.addEventListener('submit', handleSubmit); + } +}); + +const parseIdentifyUserError = async (error: unknown) => { + if (error instanceof Response) { + const errorBody = await error.json(); + const [_, data] = validate(errorBody.data, socialAccountNotExistErrorDataGuard); + + return { ...errorBody, data }; + } + throw error; +}; + +const handleSubmit = async (event: Event) => { + event.preventDefault(); + setSubmitLoading(true); + clearError(); + + try { + const state = generateRandomString(8); + + await api.experience.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + const { verificationId, authorizationUri } = await api.experience.createSocialVerification( + connectorId, + { + state, + redirectUri: `${window.location.origin}/callback/${connectorId}`, + } + ); + + sessionStorage.setItem('verificationId', verificationId); + sessionStorage.setItem('state', state); + + window.location.assign(authorizationUri); + } catch (error) { + handleError(error); + setSubmitLoading(false); + } +}; + +const handleSocialCallback = async () => { + document.querySelector('form')?.classList.add('hidden'); + const { state, ...connectorData } = parseQueryParameters(window.location.search); + const verificationId = sessionStorage.getItem('verificationId'); + const stateInStorage = sessionStorage.getItem('state'); + + try { + if (!verificationId || !state || state !== stateInStorage) { + throw new Error('Invalid session.'); + } + + await api.experience.verifySocialVerification(connectorId, { + verificationId, + connectorData, + }); + + try { + await api.experience.identifyUser({ verificationId }); + } catch (error) { + const { code, message, data } = await parseIdentifyUserError(error); + if (code === 'user.identity_not_exist' && data?.relatedUser) { + await api.experience.identifyUser({ verificationId, linkSocialIdentity: true }); + return; + } + if (code === 'user.identity_not_exist' && !data?.relatedUser) { + await api.experience.updateInteractionEvent({ + interactionEvent: InteractionEvent.Register, + }); + await api.experience.identifyUser({ verificationId }); + return; + } + throw new Error(message); + } + + const { redirectTo } = await api.experience.submitInteraction(); + window.location.replace(redirectTo); + } catch (error) { + handleError(error); + setSubmitLoading(false); + } +}; diff --git a/packages/social-sign-in-and-register/src/utils.ts b/packages/social-sign-in-and-register/src/utils.ts new file mode 100644 index 0000000..194548e --- /dev/null +++ b/packages/social-sign-in-and-register/src/utils.ts @@ -0,0 +1,30 @@ +import { fromUint8Array } from 'js-base64'; + +export const generateRandomString = (length: number) => { + return fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true); +}; + +export const parseQueryParameters = (search: string) => { + const searchParameters = new URLSearchParams(search); + + return Object.fromEntries( + [...searchParameters.entries()].map(([key, value]) => [key, decodeURIComponent(value)]) + ); +}; + +export const handleError = (error: unknown) => { + const errorContainer = document.querySelector('.error-message'); + const submitButton = document.querySelector('.submit-button'); + + console.error(error); + if (errorContainer) { + errorContainer.classList.remove('hidden'); + errorContainer.innerHTML = + error instanceof Error + ? error.message + : 'Error occurred. Please check debugger console for details.'; + } + + submitButton?.removeAttribute('disabled'); + submitButton?.classList.remove('loading'); +}; diff --git a/packages/social-sign-in-and-register/tsconfig.json b/packages/social-sign-in-and-register/tsconfig.json new file mode 100644 index 0000000..b962e88 --- /dev/null +++ b/packages/social-sign-in-and-register/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53b17e5..dfc7c98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,46 @@ importers: specifier: ^5.4.0 version: 5.4.6(@types/node@22.3.0)(sass-embedded@1.77.8) + packages/social-sign-in-and-register: + dependencies: + js-base64: + specifier: ^3.7.7 + version: 3.7.7 + superstruct: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@logto/experience-sample-shared': + specifier: workspace:^ + version: link:../shared + '@logto/schemas': + specifier: ^1.19.0 + version: 1.19.0(zod@3.23.8) + '@silverhand/eslint-config': + specifier: ^6.0.1 + version: 6.0.1(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4) + '@silverhand/ts-config': + specifier: ^6.0.0 + version: 6.0.0(typescript@5.5.4) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + lint-staged: + specifier: ^15.0.0 + version: 15.2.9 + prettier: + specifier: ^3.0.0 + version: 3.3.3 + stylelint: + specifier: ^15.0.0 + version: 15.11.0(typescript@5.5.4) + typescript: + specifier: ^5.5.3 + version: 5.5.4 + vite: + specifier: ^5.4.6 + version: 5.4.6(@types/node@22.3.0)(sass-embedded@1.77.8) + packages: '@babel/code-frame@7.24.7': @@ -948,7 +988,7 @@ packages: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} confusing-browser-globals@1.0.11: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} @@ -1802,6 +1842,9 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2664,6 +2707,10 @@ packages: engines: {node: ^14.13.1 || >=16.0.0} hasBin: true + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -4657,6 +4704,8 @@ snapshots: jiti@1.21.6: {} + js-base64@3.7.7: {} + js-tokens@4.0.0: {} js-types@1.0.0: {} @@ -5520,6 +5569,8 @@ snapshots: - supports-color - typescript + superstruct@2.0.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0