Skip to content

feat: social sign-in and register #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/shared/scss/normalized.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ form {
gap: 20px;
border-radius: 6px;
background-color: var(--color-static-white);

&.hidden {
display: none;
}
}

.input-field {
Expand Down
1 change: 1 addition & 0 deletions packages/sign-in-with-verification-code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ window.addEventListener('load', () => {
window.location.replace(redirectTo);
} catch (error) {
handleError(error);
setSubmitLoading(false);
}
});
});
1 change: 1 addition & 0 deletions packages/sign-up-with-verification-code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ window.addEventListener('load', () => {
window.location.replace(redirectTo);
} catch (error) {
handleError(error);
setSubmitLoading(false);
}
});
});
25 changes: 25 additions & 0 deletions packages/social-sign-in-and-register/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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',
},
},
],
};
Binary file not shown.
24 changes: 24 additions & 0 deletions packages/social-sign-in-and-register/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Logto experience sample</title>
<link rel="icon" href="./favicon.ico" />
<script type="module" src="src/index.js"></script>
</head>

<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div class="app">
<form>
<img fetchpriority="high" class="logo" src="https://logto.io/logo.svg" alt="Logto branding logo" />
<button class="button" type="submit">
<span>Continue with Google</span>
<div class="spinner"></div>
</button>
</form>
<div class="error-message hidden"></div>
</div>
</body>
</html>
36 changes: 36 additions & 0 deletions packages/social-sign-in-and-register/package.json
Original file line number Diff line number Diff line change
@@ -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. <contact@silverhand.io>",
"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"
}
}
13 changes: 13 additions & 0 deletions packages/social-sign-in-and-register/src/consts.ts
Original file line number Diff line number Diff line change
@@ -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(),
}),
});
3 changes: 3 additions & 0 deletions packages/social-sign-in-and-register/src/include.d/dom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Body {
json<T>(): Promise<T>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'vite/client';
97 changes: 97 additions & 0 deletions packages/social-sign-in-and-register/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<RequestErrorBody>();
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);
}
};
30 changes: 30 additions & 0 deletions packages/social-sign-in-and-register/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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');
};
11 changes: 11 additions & 0 deletions packages/social-sign-in-and-register/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
53 changes: 52 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading