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