From 5bd63c8fbf6ae5d7c2523a110e5847aea4569aa5 Mon Sep 17 00:00:00 2001 From: Aleksey Legotkin Date: Wed, 18 Oct 2023 23:51:01 +0300 Subject: [PATCH] Release v0.0.1 --- .editorconfig | 14 + .eslintignore | 6 + .eslintrc-es5.js | 9 + .eslintrc-ts.js | 40 + .eslintrc.js | 26 + .gitignore | 15 + .gitlab/merge_request_templates/Default.md | 37 + .husky/common.sh | 8 + .husky/pre-commit | 5 + .husky/pre-push | 5 + .wiki/.nojekyll | 1 + .wiki/README.md | 101 + .wiki/classes/auth.Auth.md | 47 + .wiki/classes/core_config.Config.md | 48 + .wiki/enums/auth.AuthErrorCode.md | 46 + .wiki/interfaces/auth.AuthError.md | 37 + .wiki/interfaces/auth.AuthParams.md | 32 + .wiki/interfaces/auth.AuthResponse.md | 37 + .wiki/interfaces/core_config.ConfigData.md | 52 + .wiki/modules.md | 10 + .wiki/modules/auth.md | 19 + .wiki/modules/core_config.md | 13 + CODE_OF_CONDUCT.md | 0 CODE_STYLE.md | 204 + CONTRIBUTING.md | 92 + LICENSE | 4 + README.md | 99 + Readme.md | 3 - __tests__/auth/auth.tests.ts | 144 + __tests__/auth/authDataService.tests.ts | 90 + __tests__/constants.ts | 2 + __tests__/core/config/config.tests.ts | 32 + .../core/dataService/dataService.tests.ts | 51 + __tests__/core/dispatcher/dispatcher.tests.ts | 69 + .../core/validator/validationRule.tests.ts | 68 + __tests__/core/validator/validator.tests.ts | 76 + __tests__/core/widget/widget.tests.ts | 110 + __tests__/jest-global-mock.js | 26 + __tests__/tsconfig.json | 4 + __tests__/utils.ts | 16 + .../agreementsDialog.tests.ts | 77 + __tests__/widgets/captcha/captcha.tests.ts | 33 + .../widgets/dataPolicy/dataPolicy.tests.ts | 33 + __tests__/widgets/oneTap/oneTap.tests.ts | 190 + demo/components/snackbar.ts | 64 + demo/index.html | 59 + demo/index.ts | 47 + demo/styles.css | 29 + demo/styles/button.css | 73 + demo/styles/snackbar.css | 27 + demo/styles/variables.css | 17 + jest.config.ts | 211 + package.json | 97 + rollup.demo.config.js | 66 + rollup.sdk.config.js | 83 + src/auth/auth.ts | 85 + src/auth/authDataService.ts | 43 + src/auth/constants.ts | 12 + src/auth/index.ts | 2 + src/auth/types.ts | 78 + src/constants.ts | 13 + src/core/bridge/bridge.ts | 49 + src/core/bridge/index.ts | 3 + src/core/bridge/types.ts | 18 + src/core/config/config.ts | 22 + src/core/config/index.ts | 2 + src/core/config/types.ts | 10 + src/core/dataService/dataService.ts | 35 + src/core/dataService/index.ts | 1 + src/core/dataService/types.ts | 0 src/core/dispatcher/dispatcher.ts | 15 + src/core/dispatcher/index.ts | 1 + src/core/validator/index.ts | 2 + src/core/validator/rules.ts | 41 + src/core/validator/types.ts | 4 + src/core/validator/validator.ts | 26 + src/core/widget/constants.ts | 6 + src/core/widget/events.ts | 8 + src/core/widget/index.ts | 3 + src/core/widget/template.ts | 30 + src/core/widget/types.ts | 36 + src/core/widget/widget.ts | 178 + src/index.ts | 19 + src/types.ts | 10 + src/utils/domain.ts | 6 + src/utils/oauth.ts | 17 + src/utils/styles.ts | 27 + src/utils/url.ts | 20 + .../agreementsDialog/agreementsDialog.ts | 26 + src/widgets/agreementsDialog/events.ts | 5 + src/widgets/agreementsDialog/index.ts | 1 + src/widgets/agreementsDialog/template.ts | 22 + src/widgets/captcha/captcha.ts | 23 + src/widgets/captcha/events.ts | 13 + src/widgets/captcha/index.ts | 1 + src/widgets/captcha/template.ts | 22 + src/widgets/captcha/types.ts | 4 + src/widgets/dataPolicy/dataPolicy.ts | 22 + src/widgets/dataPolicy/events.ts | 3 + src/widgets/dataPolicy/index.ts | 1 + src/widgets/dataPolicy/template.ts | 22 + src/widgets/oneTap/events.ts | 13 + src/widgets/oneTap/index.ts | 3 + src/widgets/oneTap/oneTap.ts | 98 + src/widgets/oneTap/template.ts | 362 + src/widgets/oneTap/types.ts | 31 + tsconfig.eslint.json | 4 + tsconfig.json | 25 + tsconfig.sdk.json | 9 + typedoc.json | 12 + yarn.lock | 6496 +++++++++++++++++ 111 files changed, 10841 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc-es5.js create mode 100644 .eslintrc-ts.js create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .gitlab/merge_request_templates/Default.md create mode 100644 .husky/common.sh create mode 100755 .husky/pre-commit create mode 100644 .husky/pre-push create mode 100644 .wiki/.nojekyll create mode 100644 .wiki/README.md create mode 100644 .wiki/classes/auth.Auth.md create mode 100644 .wiki/classes/core_config.Config.md create mode 100644 .wiki/enums/auth.AuthErrorCode.md create mode 100644 .wiki/interfaces/auth.AuthError.md create mode 100644 .wiki/interfaces/auth.AuthParams.md create mode 100644 .wiki/interfaces/auth.AuthResponse.md create mode 100644 .wiki/interfaces/core_config.ConfigData.md create mode 100644 .wiki/modules.md create mode 100644 .wiki/modules/auth.md create mode 100644 .wiki/modules/core_config.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CODE_STYLE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md delete mode 100644 Readme.md create mode 100644 __tests__/auth/auth.tests.ts create mode 100644 __tests__/auth/authDataService.tests.ts create mode 100644 __tests__/constants.ts create mode 100644 __tests__/core/config/config.tests.ts create mode 100644 __tests__/core/dataService/dataService.tests.ts create mode 100644 __tests__/core/dispatcher/dispatcher.tests.ts create mode 100644 __tests__/core/validator/validationRule.tests.ts create mode 100644 __tests__/core/validator/validator.tests.ts create mode 100644 __tests__/core/widget/widget.tests.ts create mode 100644 __tests__/jest-global-mock.js create mode 100644 __tests__/tsconfig.json create mode 100644 __tests__/utils.ts create mode 100644 __tests__/widgets/agreementsDialog/agreementsDialog.tests.ts create mode 100644 __tests__/widgets/captcha/captcha.tests.ts create mode 100644 __tests__/widgets/dataPolicy/dataPolicy.tests.ts create mode 100644 __tests__/widgets/oneTap/oneTap.tests.ts create mode 100644 demo/components/snackbar.ts create mode 100644 demo/index.html create mode 100644 demo/index.ts create mode 100644 demo/styles.css create mode 100644 demo/styles/button.css create mode 100644 demo/styles/snackbar.css create mode 100644 demo/styles/variables.css create mode 100644 jest.config.ts create mode 100644 package.json create mode 100644 rollup.demo.config.js create mode 100644 rollup.sdk.config.js create mode 100644 src/auth/auth.ts create mode 100644 src/auth/authDataService.ts create mode 100644 src/auth/constants.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/types.ts create mode 100644 src/constants.ts create mode 100644 src/core/bridge/bridge.ts create mode 100644 src/core/bridge/index.ts create mode 100644 src/core/bridge/types.ts create mode 100644 src/core/config/config.ts create mode 100644 src/core/config/index.ts create mode 100644 src/core/config/types.ts create mode 100644 src/core/dataService/dataService.ts create mode 100644 src/core/dataService/index.ts create mode 100644 src/core/dataService/types.ts create mode 100644 src/core/dispatcher/dispatcher.ts create mode 100644 src/core/dispatcher/index.ts create mode 100644 src/core/validator/index.ts create mode 100644 src/core/validator/rules.ts create mode 100644 src/core/validator/types.ts create mode 100644 src/core/validator/validator.ts create mode 100644 src/core/widget/constants.ts create mode 100644 src/core/widget/events.ts create mode 100644 src/core/widget/index.ts create mode 100644 src/core/widget/template.ts create mode 100644 src/core/widget/types.ts create mode 100644 src/core/widget/widget.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 src/utils/domain.ts create mode 100644 src/utils/oauth.ts create mode 100644 src/utils/styles.ts create mode 100644 src/utils/url.ts create mode 100644 src/widgets/agreementsDialog/agreementsDialog.ts create mode 100644 src/widgets/agreementsDialog/events.ts create mode 100644 src/widgets/agreementsDialog/index.ts create mode 100644 src/widgets/agreementsDialog/template.ts create mode 100644 src/widgets/captcha/captcha.ts create mode 100644 src/widgets/captcha/events.ts create mode 100644 src/widgets/captcha/index.ts create mode 100644 src/widgets/captcha/template.ts create mode 100644 src/widgets/captcha/types.ts create mode 100644 src/widgets/dataPolicy/dataPolicy.ts create mode 100644 src/widgets/dataPolicy/events.ts create mode 100644 src/widgets/dataPolicy/index.ts create mode 100644 src/widgets/dataPolicy/template.ts create mode 100644 src/widgets/oneTap/events.ts create mode 100644 src/widgets/oneTap/index.ts create mode 100644 src/widgets/oneTap/oneTap.ts create mode 100644 src/widgets/oneTap/template.ts create mode 100644 src/widgets/oneTap/types.ts create mode 100644 tsconfig.eslint.json create mode 100644 tsconfig.json create mode 100644 tsconfig.sdk.json create mode 100644 typedoc.json create mode 100644 yarn.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..63c9675 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3a7c46e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +node_modules/ +dist-sdk/ +dist-demo/ +dist-docs/ +dist-coverage/ +dist-specification/ diff --git a/.eslintrc-es5.js b/.eslintrc-es5.js new file mode 100644 index 0000000..1eb2fc1 --- /dev/null +++ b/.eslintrc-es5.js @@ -0,0 +1,9 @@ +module.exports = { + env: { + browser: true, + node: true, + }, + parserOptions: { + ecmaVersion: 5, + }, +}; diff --git a/.eslintrc-ts.js b/.eslintrc-ts.js new file mode 100644 index 0000000..183b2fc --- /dev/null +++ b/.eslintrc-ts.js @@ -0,0 +1,40 @@ +module.exports = { + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + }, + extends: ['@vkontakte/eslint-config/typescript'], + plugins: ['eslint-plugin-import'], + rules: { + 'import/order': [ + 'error', + { + 'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always', + 'alphabetize': { + order: 'asc', + }, + 'pathGroups': [ + { + pattern: '#/**', + group: 'internal', + }, + ], + }, + ], + + // Disabled because: no configurable options for .length > 0, arr[0] and similar constructions. + 'no-magic-numbers': 'off', + '@typescript-eslint/no-magic-numbers': 'off', + + // Disabled because: errors on 'displayMode || '5min'' expression with nullable variable. + '@typescript-eslint/no-unnecessary-condition': 'off', + + // Disabled: covered with stricter tsc settings + '@typescript-eslint/typedef': 'off', + + '@typescript-eslint/prefer-string-starts-ends-with': 'off', + + 'no-shadow': 'off', + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..eb43210 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + overrides: [{ + files: ['demo/**/*.ts', 'src/**/*.ts', '__tests__/**/*.ts'], + extends: ['./.eslintrc-ts.js'] + }], + env: { + browser: true, + node: true, + es6: true, + jest: true + }, + parserOptions: { + ecmaVersion: 2018, + // Allows for the parsing of modern ECMAScript features + sourceType: 'module', + // Allows for the use of imports + ecmaFeatures: { + restParams: true, + spread: true + } + }, + globals: { + process: true + }, + extends: ["plugin:storybook/recommended", "plugin:storybook/recommended"] +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcaac69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules +npm-debug.log +.idea/ +.vscode/ +yarn-error.log +vscode +dist-sdk/ +dist-demo/ +dist-specification/ +dist-coverage/ +.eslintcache +coverage +*.DS_Store +allure-report/ +allure-results/ diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000..9ec9010 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,37 @@ +## Merge Checklist + +* [ ] Подлит свежий develop +* [ ] Код проверен на тесте и он работает +* [ ] Код покрыт тестами (если возможно) +* [ ] В демку внесены изменения (если возможно) +* [ ] Документация обновлена + +## Описание изменений + + +## Обоснование изменений + + +## Реализация + + +## Тестирование + + +## Примечания + diff --git a/.husky/common.sh b/.husky/common.sh new file mode 100644 index 0000000..b821568 --- /dev/null +++ b/.husky/common.sh @@ -0,0 +1,8 @@ +command_exists () { + command -v "$1" >/dev/null 2>&1 +} + +# Windows 10, Git Bash and Yarn workaround +if command_exists winpty && test -t 1; then + exec < /dev/tty +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..ee3e5fe --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" +. "$(dirname "$0")/common.sh" + +yarn lint-staged && yarn docs:prod && git add ./.wiki diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..3bbee36 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" +. "$(dirname "$0")/common.sh" + +yarn tests diff --git a/.wiki/.nojekyll b/.wiki/.nojekyll new file mode 100644 index 0000000..e2ac661 --- /dev/null +++ b/.wiki/.nojekyll @@ -0,0 +1 @@ +TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. \ No newline at end of file diff --git a/.wiki/README.md b/.wiki/README.md new file mode 100644 index 0000000..2bf9f2e --- /dev/null +++ b/.wiki/README.md @@ -0,0 +1,101 @@ +@vkid/sdk / [Modules](modules.md) + +
+

+ +

+

+ + + + + + + + + npm + + + npm bundle size + +

+

+ VK ID SDK — это библиотека для безопасной и удобной авторизации пользователей в вашем сервисе через VK ID. +

+
+ +## Демонстрация + +Чтобы изучить возможности VK ID SDK, перейдите на [демо-стенд](https://demo.vksak.com) + +## Установка + +**NPM:** + +```sh +npm i @vkid/sdk +``` + +**YARN:** + +```sh +yarn add @vkid/sdk +``` + +**PNPM:** + +```sh +pnpm add @vkid/sdk +``` + +**CDN:** + +```html + +``` + +## Пример + +```javascript +import * as VKID from '@vkid/sdk'; + +VKID.Config.set({ + app: APP_ID +}); + +const handleSuccess = (token) => { + console.log(token); +} + +const authButton = document.createElement('button'); +authButton.onclick = () => { + VKID.Auth.login() + .then(handleSuccess) + .catch(console.error); +}; + +document.getElementById('container') + .appendChild(authButton); +``` + +> Обратите внимание: Для работы авторизации нужен APP_ID. Вы получите его, когда [создадите](https://id.vk.com/business/go/docs/vkid/latest/create-application) приложение в кабинете подключения VK ID. + +## Документация + +- [Что такое VK ID](https://id.vk.com/business/go/docs/vkid/latest/start-page) +- [Создание приложения](https://id.vk.com/business/go/docs/vkid/latest/create-application) +- [Требования к дизайну](https://id.vk.com/business/go/docs/vkid/latest/guidelines/design-rules) +- [Спецификация](.wiki/README.md) + +## Contributing + +Проект VK ID SDK имеет открытый исходный код на GitHub, и вы можете присоединиться к его доработке — мы будем благодарны за внесение улучшений и исправление возможных ошибок. + +### Code of Conduct + +Если вы собираетесь вносить изменения в проект VK ID SDK, следуйте правилам [разработки](CODE_OF_CONDUCT.md). Они помогут понять, какие действия возможны, а какие недопустимы. + +### Contributing Guide + +В [руководстве](CONTRIBUTING.md) вы можете подробно ознакомиться с процессом разработки и узнать, как предлагать улучшения и исправления, а ещё — как добавлять и тестировать свои изменения в VK ID SDK. +Также рекомендуем ознакомиться с общими [правилами оформления кода](CODE_STYLE.md) в проекте. diff --git a/.wiki/classes/auth.Auth.md b/.wiki/classes/auth.Auth.md new file mode 100644 index 0000000..8fb058b --- /dev/null +++ b/.wiki/classes/auth.Auth.md @@ -0,0 +1,47 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [auth](../modules/auth.md) / Auth + +# Class: Auth + +[auth](../modules/auth.md).Auth + +## Table of contents + +### Constructors + +- [constructor](auth.Auth.md#constructor) + +### Properties + +- [\_\_config](auth.Auth.md#__config) + +### Methods + +- [login](auth.Auth.md#login) + +## Constructors + +### constructor + +• **new Auth**() + +## Properties + +### \_\_config + +▪ `Static` **\_\_config**: [`Config`](core_config.Config.md) + +## Methods + +### login + +▸ `Readonly` **login**(`params?`): `Promise`<[`AuthResponse`](../interfaces/auth.AuthResponse.md)\> + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params?` | [`AuthParams`](../interfaces/auth.AuthParams.md) | + +#### Returns + +`Promise`<[`AuthResponse`](../interfaces/auth.AuthResponse.md)\> diff --git a/.wiki/classes/core_config.Config.md b/.wiki/classes/core_config.Config.md new file mode 100644 index 0000000..d7abac8 --- /dev/null +++ b/.wiki/classes/core_config.Config.md @@ -0,0 +1,48 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [core/config](../modules/core_config.md) / Config + +# Class: Config + +[core/config](../modules/core_config.md).Config + +## Table of contents + +### Constructors + +- [constructor](core_config.Config.md#constructor) + +### Methods + +- [get](core_config.Config.md#get) +- [set](core_config.Config.md#set) + +## Constructors + +### constructor + +• **new Config**() + +## Methods + +### get + +▸ **get**(): [`ConfigData`](../interfaces/core_config.ConfigData.md) + +#### Returns + +[`ConfigData`](../interfaces/core_config.ConfigData.md) + +___ + +### set + +▸ **set**(`config`): [`Config`](core_config.Config.md) + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `config` | `Partial`<[`ConfigData`](../interfaces/core_config.ConfigData.md)\> | + +#### Returns + +[`Config`](core_config.Config.md) diff --git a/.wiki/enums/auth.AuthErrorCode.md b/.wiki/enums/auth.AuthErrorCode.md new file mode 100644 index 0000000..0d6667d --- /dev/null +++ b/.wiki/enums/auth.AuthErrorCode.md @@ -0,0 +1,46 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [auth](../modules/auth.md) / AuthErrorCode + +# Enumeration: AuthErrorCode + +[auth](../modules/auth.md).AuthErrorCode + +## Table of contents + +### Enumeration Members + +- [AuthorizationFailed](auth.AuthErrorCode.md#authorizationfailed) +- [CannotCreateNewTab](auth.AuthErrorCode.md#cannotcreatenewtab) +- [EventNotSupported](auth.AuthErrorCode.md#eventnotsupported) +- [NewTabHasBeenClosed](auth.AuthErrorCode.md#newtabhasbeenclosed) + +## Enumeration Members + +### AuthorizationFailed + +• **AuthorizationFailed** = ``103`` + +Авторизация завершилась ошибкой + +___ + +### CannotCreateNewTab + +• **CannotCreateNewTab** = ``101`` + +Новая вкладка не создалась + +___ + +### EventNotSupported + +• **EventNotSupported** = ``100`` + +Неизвестное событие + +___ + +### NewTabHasBeenClosed + +• **NewTabHasBeenClosed** = ``102`` + +Новая вкладка была закрыта diff --git a/.wiki/interfaces/auth.AuthError.md b/.wiki/interfaces/auth.AuthError.md new file mode 100644 index 0000000..111d084 --- /dev/null +++ b/.wiki/interfaces/auth.AuthError.md @@ -0,0 +1,37 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [auth](../modules/auth.md) / AuthError + +# Interface: AuthError + +[auth](../modules/auth.md).AuthError + +## Table of contents + +### Properties + +- [code](auth.AuthError.md#code) +- [details](auth.AuthError.md#details) +- [text](auth.AuthError.md#text) + +## Properties + +### code + +• **code**: [`AuthErrorCode`](../enums/auth.AuthErrorCode.md) + +Код ошибки + +___ + +### details + +• `Optional` **details**: `any` + +Расширенная информация об ошибке + +___ + +### text + +• **text**: `string` + +Текст ошибки diff --git a/.wiki/interfaces/auth.AuthParams.md b/.wiki/interfaces/auth.AuthParams.md new file mode 100644 index 0000000..f2f9393 --- /dev/null +++ b/.wiki/interfaces/auth.AuthParams.md @@ -0,0 +1,32 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [auth](../modules/auth.md) / AuthParams + +# Interface: AuthParams + +[auth](../modules/auth.md).AuthParams + +## Indexable + +▪ [key: `string`]: `any` + +## Table of contents + +### Properties + +- [lang](auth.AuthParams.md#lang) +- [scheme](auth.AuthParams.md#scheme) + +## Properties + +### lang + +• `Optional` **lang**: `Languages` + +Локализация, в которой будет отображена страница авторизации + +___ + +### scheme + +• `Optional` **scheme**: ``"bright_light"`` \| ``"space_gray"`` + +Цветовая тема, в которой будет отображена страница авторизации diff --git a/.wiki/interfaces/auth.AuthResponse.md b/.wiki/interfaces/auth.AuthResponse.md new file mode 100644 index 0000000..2514c6b --- /dev/null +++ b/.wiki/interfaces/auth.AuthResponse.md @@ -0,0 +1,37 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [auth](../modules/auth.md) / AuthResponse + +# Interface: AuthResponse + +[auth](../modules/auth.md).AuthResponse + +## Table of contents + +### Properties + +- [token](auth.AuthResponse.md#token) +- [ttl](auth.AuthResponse.md#ttl) +- [type](auth.AuthResponse.md#type) + +## Properties + +### token + +• **token**: `string` + +Токен, полученный после прохождения авторизации + +___ + +### ttl + +• **ttl**: `number` + +Время жизни токена + +___ + +### type + +• **type**: ``"silent_token"`` + +Вид токена diff --git a/.wiki/interfaces/core_config.ConfigData.md b/.wiki/interfaces/core_config.ConfigData.md new file mode 100644 index 0000000..c7e9281 --- /dev/null +++ b/.wiki/interfaces/core_config.ConfigData.md @@ -0,0 +1,52 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / [core/config](../modules/core_config.md) / ConfigData + +# Interface: ConfigData + +[core/config](../modules/core_config.md).ConfigData + +## Table of contents + +### Properties + +- [\_\_debug](core_config.ConfigData.md#__debug) +- [\_\_localhost](core_config.ConfigData.md#__localhost) +- [app](core_config.ConfigData.md#app) +- [loginDomain](core_config.ConfigData.md#logindomain) +- [oauthDomain](core_config.ConfigData.md#oauthdomain) +- [vkidDomain](core_config.ConfigData.md#vkiddomain) + +## Properties + +### \_\_debug + +• `Optional` **\_\_debug**: `boolean` + +___ + +### \_\_localhost + +• `Optional` **\_\_localhost**: `boolean` + +___ + +### app + +• **app**: `number` + +___ + +### loginDomain + +• **loginDomain**: `string` + +___ + +### oauthDomain + +• **oauthDomain**: `string` + +___ + +### vkidDomain + +• **vkidDomain**: `string` diff --git a/.wiki/modules.md b/.wiki/modules.md new file mode 100644 index 0000000..9d287a6 --- /dev/null +++ b/.wiki/modules.md @@ -0,0 +1,10 @@ +[@vkid/sdk - v0.0.1](README.md) / Modules + +# @vkid/sdk - v0.0.1 + +## Table of contents + +### Modules + +- [auth](modules/auth.md) +- [core/config](modules/core_config.md) diff --git a/.wiki/modules/auth.md b/.wiki/modules/auth.md new file mode 100644 index 0000000..b5ee64c --- /dev/null +++ b/.wiki/modules/auth.md @@ -0,0 +1,19 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / auth + +# Module: auth + +## Table of contents + +### Enumerations + +- [AuthErrorCode](../enums/auth.AuthErrorCode.md) + +### Classes + +- [Auth](../classes/auth.Auth.md) + +### Interfaces + +- [AuthError](../interfaces/auth.AuthError.md) +- [AuthParams](../interfaces/auth.AuthParams.md) +- [AuthResponse](../interfaces/auth.AuthResponse.md) diff --git a/.wiki/modules/core_config.md b/.wiki/modules/core_config.md new file mode 100644 index 0000000..f490ad4 --- /dev/null +++ b/.wiki/modules/core_config.md @@ -0,0 +1,13 @@ +[@vkid/sdk - v0.0.1](../README.md) / [Modules](../modules.md) / core/config + +# Module: core/config + +## Table of contents + +### Classes + +- [Config](../classes/core_config.Config.md) + +### Interfaces + +- [ConfigData](../interfaces/core_config.ConfigData.md) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e69de29 diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000..f8fe568 --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,204 @@ +# Code Style + +## Общие правила + ++ Используйте краткие и описательные имена переменных, функций и классов. ++ Поддерживайте чистоту кода, избегая дублирования и излишней сложности. ++ Пишите комментарии только там, где это необходимо для пояснения сложного или нетривиального кода. + +## Форматирование кода + ++ Используйте 2 пробела в качестве отступа (для каждого уровня вложенности). ++ Используйте одинарные кавычки (') для строковых литералов. ++ Размещайте открывающую фигурную скобку на той же строке, что и соответствующая конструкция (функция, класс, условие и т.д.). ++ Используйте точку с запятой (;) в конце каждого выражения. + +```typescript +// Пример форматирования кода + +function greet(name: string): void { + console.log('Hello, ' + name); +} + +if (condition) { + // ... +} else { + // ... +} + +class MyClass { + // ... +} +``` + +## Объявление и наименование переменных + ++ Используйте ключевое слово const для объявления переменных, значения которых не изменяются. ++ Используйте ключевое слово let для объявления переменных, значения которых могут изменяться. ++ Избегайте использования ключевого слова var для объявления переменных. ++ Используйте camelCase для имен переменных. ++ Используйте описательные имена переменных, чтобы обеспечить понятность их назначения. ++ Числовые и строковые константы должны быть названы в верхнем регистре. + +```typescript +// Пример объявления переменных +const PI = 3.14; +let counter = 0; + +// Избегайте использования var +var x = 5; + +// Пример наименования переменных +const FIRST_NAME = 'John'; +let age = 30; +``` + +## Функции + ++ Используйте camelCase для имен функций. ++ Используйте описательные имена функций, отражающие их назначение и выполняемые действия. + +```typescript +// Пример объявления функций + +function calculateSum(a: number, b: number): number { + return a + b; +} + +function printMessage(message: string): void { + console.log(message); +} +``` + +## Классы + ++ Используйте PascalCase для имен классов. ++ Используйте описательные имена классов, отражающие их назначение и роль в системе. + +```typescript +// Пример объявления класса + +class UserService { + // ... +} +``` + +## Экспорт и импорт модулей + ++ Используйте именованный экспорт (export) для экспорта функций, классов или переменных из модуля. ++ Не используйте дефолтный экспорт (export default) для экспорта функций, классов или переменных из модуля. ++ Импортируйте экспортируемые элементы с использованием фигурных скобок {}. + +```typescript +// Пример экспорта и импорта модулей + +// Файл moduleA.ts +export function greet(name: string): void { + console.log('Hello, ' + name); +} + +// Файл moduleB.ts +import { greet } from './moduleA'; +``` + +## Условные конструкции + ++ Используйте фигурные скобки для отделения блока кода, даже если он в одну строку ++ Не используйте вычисления внутри if ++ Не используйте длинные составные условия ++ Используйте явное сравнение с null или undefined ++ Избегайте использования вложенных условий ++ Используйте тернарный оператор для простых проверок + +```typescript +// Плохо (без фигурных скобок) +if (condition) doSomething(); +// Хорошо (легко понять область исполнения кода) +if (condition) { + doSomething(); +} + +// Плохо (используется вычисление внутри if) +if (arr.includes(value)) {} +// Хорошо (используется константа с понятным названием) +const isValid = arr.includes(value); +if (isValid) {} + +// Плохо (используется длинное составное условие) +if ((countryCode === 'RU' || countryCode === 'KZ') && phoneNumber.length > 12) {} +// Хорошо (используется константа с понятным названием) +const isValidPhone = (countryCode === 'RU' || countryCode === 'KZ') && phoneNumber.length > 12; +if (isValidPhone) {} + +// Плохо (используется неявное преобразование типов) +if (value == null) {} +// Хорошо (используется явное сравнение) +if (value === null) {} + +// Плохо (вложенные условия) +if (condition1) { + if (condition2) { + // ... + } +} +// Хорошо (без вложенных условий) +if (condition1 && condition2) {} + +// Плохо (используется if-else для простой проверки) +let result; +if (condition) { + result = 'Condition is true'; +} else { + result = 'Condition is false'; +} +// Хорошо (используется тернарный оператор) +const result = condition ? 'Condition is true' : 'Condition is false'; +``` + +## Структура нового модуля + +> Модули находятся в папке `src/{module_name}`. + +Структура модуля: + +```bash +modulename + index.ts + types.ts + events.ts + constants.ts + moduleName.ts +``` + ++ moduleName.ts - основной файл, где находится код модуля ++ types.ts - файл с типами модуля ++ events.ts - события модуля, если они есть ++ constants.ts - константы, используемые в модуле ++ index.ts - файл для экспорта типов и самого модуля. На основе этого модуля создается документация, поэтому все публичные типы для модуля должны быть экспортированы. [Пример](./src/auth/index.ts) + +## Структура директории тестов + +> Тесты находятся в папке `__tests__/{module_name}` + +Файлы тестов должны иметь в названии `.tests.`. Пример: + +```bash +__tests__/validator/validator.tests.ts +``` + +### `__tests__` - тут лежат файлы с jest тестами + ++ `{module_name}` + + {suite_name}.tests.ts ++ jest-global-mock.js - моки методов и переменных ++ utils.ts - полезные функции для использования в тестах + +## Структура файлов Web SDK + +### `src` - тут лежат файлы web sdk + ++ `core` - классы и методы, которые испольльзуются при создании модулей ++ `{module_name}` - файлы модуля ++ `utils` - полезные функции для использования в проекте ++ constants.ts - общие константы для использования в проекте ++ index.ts - файл с публичными экспортами diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..78274e4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing + +## ⏳ Подготовка к разработке + +1. Склонировать репозиторий и перейти в созданную директорию. +2. Установить зависимости. + +```bash +git clone git@https://github.com/VKCOM/vkid-web-sdk.git +cd web-sdk +yarn install +``` + +## 🐶 Базовые команды + +> Используйте `prod` вместо `dev` в команде запуска для сборки итогового бандла + +1. `yarn sdk:dev` - сборка проекта с отслеживанием изменений +2. `yarn docs:dev` - сборка документации с отслеживанием изменений +3. `yarn demo:dev` - сборка демо стенда с отслеживанием изменений +4. `yarn tests` - прогон unit тестов + +## 🪵 Создание ветки + +Ветки делаются от `develop`. + +Для названия веток используется специальный шаблон: + +```bash +{username}/{task_type}/{description}/{issue_number} +``` + +Пример: + +```bash +git checkout develop +git pull +git checkout -b user/task/new-feature/ISSUE-1 +``` + +## 📝 Создание коммита + +Сообщение в коммите должно соответствовать шаблону. + +Для коммитов будет добавлен линтер на хук `commit-msg`, который проверяет, соответствует ли сообщение в коммите шаблону. + +Доступные шаблоны: + +```bash +{номер задачи}: краткое описание коммита на английском языке + +// ISSUE-1: change auth button color +``` + +## 😸 Подготовка Merge Request + +Для того, чтобы подготовить Merge Request, необходимо пройти [Чеклист](.gitlab/merge_request_templates/Default.md) + +## 👨‍🍳 Создание нового модуля + +Правила структуры для создания нового модуля расположены в [Code Style](./CODE_STYLE.md). + +## 🐛 Тесты + +В репозитории используются `unit` тесты на jest. +Запуск тестов выполняется командой: + +```bash +yarn tests +``` + +Правила структуры файлов с тестами описаны в [Code Style](./CODE_STYLE.md). + +## 🖊️ Документация + +> Автосгенерированная документация находится в папке `.wiki/` + +Документация собирается с помощью [typedoc](https://typedoc.org/). + +Запустить можно командой: + +```bash +yarn docs:prod +``` + +Чтобы следить за изменениями документации локально в реальном времени, нужно выполнить команду: + +```bash +yarn docs:dev +``` + +Чтобы добавить новый модуль в документацию, надо добавить в `entryPoints` путь к `index.ts` из нового модуля в файле [typedoc.json](./typedoc.json) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..792e0c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,4 @@ +Copyright © 2023 V Kontakte LLC +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..19af11d --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +
+

+ +

+

+ + + + + + + + + npm + + + npm bundle size + +

+

+ VK ID SDK — это библиотека для безопасной и удобной авторизации пользователей в вашем сервисе через VK ID. +

+
+ +## Демонстрация + +Чтобы изучить возможности VK ID SDK, перейдите на [демо-стенд](https://demo.vksak.com) + +## Установка + +**NPM:** + +```sh +npm i @vkid/sdk +``` + +**YARN:** + +```sh +yarn add @vkid/sdk +``` + +**PNPM:** + +```sh +pnpm add @vkid/sdk +``` + +**CDN:** + +```html + +``` + +## Пример + +```javascript +import * as VKID from '@vkid/sdk'; + +VKID.Config.set({ + app: APP_ID +}); + +const handleSuccess = (token) => { + console.log(token); +} + +const authButton = document.createElement('button'); +authButton.onclick = () => { + VKID.Auth.login() + .then(handleSuccess) + .catch(console.error); +}; + +document.getElementById('container') + .appendChild(authButton); +``` + +> Обратите внимание: Для работы авторизации нужен APP_ID. Вы получите его, когда [создадите](https://id.vk.com/business/go/docs/vkid/latest/create-application) приложение в кабинете подключения VK ID. + +## Документация + +- [Что такое VK ID](https://id.vk.com/business/go/docs/vkid/latest/start-page) +- [Создание приложения](https://id.vk.com/business/go/docs/vkid/latest/create-application) +- [Требования к дизайну](https://id.vk.com/business/go/docs/vkid/latest/guidelines/design-rules) +- [Спецификация](.wiki/README.md) + +## Contributing + +Проект VK ID SDK имеет открытый исходный код на GitHub, и вы можете присоединиться к его доработке — мы будем благодарны за внесение улучшений и исправление возможных ошибок. + +### Code of Conduct + +Если вы собираетесь вносить изменения в проект VK ID SDK, следуйте правилам [разработки](CODE_OF_CONDUCT.md). Они помогут понять, какие действия возможны, а какие недопустимы. + +### Contributing Guide + +В [руководстве](CONTRIBUTING.md) вы можете подробно ознакомиться с процессом разработки и узнать, как предлагать улучшения и исправления, а ещё — как добавлять и тестировать свои изменения в VK ID SDK. +Также рекомендуем ознакомиться с общими [правилами оформления кода](CODE_STYLE.md) в проекте. diff --git a/Readme.md b/Readme.md deleted file mode 100644 index adf501c..0000000 --- a/Readme.md +++ /dev/null @@ -1,3 +0,0 @@ -# VK ID - -VK ID - SDK для авторизации пользователей web приложений с помощью аккаунта VK. diff --git a/__tests__/auth/auth.tests.ts b/__tests__/auth/auth.tests.ts new file mode 100644 index 0000000..3186b42 --- /dev/null +++ b/__tests__/auth/auth.tests.ts @@ -0,0 +1,144 @@ +import { AUTH_ERROR_TEXT, AUTH_RESPONSE_TOKEN, AUTH_VK_CONNECT_RESPONSE } from '#/auth/constants'; +import { AuthErrorCode, AuthResponse } from '#/auth/types'; +import { Auth, AuthParams, Config, Languages } from '#/index'; + +import { version } from '../../package.json'; +import { WINDOW_LOCATION_HOST } from '../constants'; + +const APP_ID = 100; + +const openFn = jest.fn(); +const closeFn = jest.fn(); +const eventListenerFn = jest.fn(); + +describe('Auth', () => { + beforeAll(() => { + window.open = openFn; + window.addEventListener = eventListenerFn; + }); + + beforeEach(() => { + Config.set({ app: APP_ID }); + }); + + test('Opens a window with default fields', async () => { + try { + await Auth.login(); + } catch (e) { + expect(openFn).toHaveBeenCalled(); + + const callArgs: string[] = openFn.mock.calls[0]; + const location = new URL(callArgs[0]); + + expect(location.search).toEqual(`?origin=https%3A%2F%2F${WINDOW_LOCATION_HOST}&response_type=${AUTH_RESPONSE_TOKEN}&v=%22${version}%22&app_id=${APP_ID}&sdk_type=vkid`); + } + }); + + test('Opens a window with additional fields', async () => { + const params: AuthParams = { + scheme: 'bright_light', + lang: '0' as Languages, + }; + + try { + await Auth.login(params); + } catch (e) { + expect(openFn).toHaveBeenCalled(); + + const callArgs: string[] = openFn.mock.calls[0]; + const location = new URL(callArgs[0]); + + expect(location.search).toEqual(`?scheme=${params.scheme}&lang_id=${params.lang}&origin=https%3A%2F%2F${WINDOW_LOCATION_HOST}&response_type=${AUTH_RESPONSE_TOKEN}&v=%22${version}%22&app_id=${APP_ID}&sdk_type=vkid`); + } + }); + + test('Must return error: cannot create new tab', async () => { + const error = { + code: AuthErrorCode.CannotCreateNewTab, + text: AUTH_ERROR_TEXT[AuthErrorCode.CannotCreateNewTab], + }; + + try { + await Auth.login(); + } catch (e) { + expect(e).toEqual(error); + } + }); + + test('Must return error: new tab has been closed', async () => { + openFn.mockReturnValue({ + closed: true, + close: closeFn, + }); + const error = { + code: AuthErrorCode.NewTabHasBeenClosed, + text: AUTH_ERROR_TEXT[AuthErrorCode.NewTabHasBeenClosed], + }; + + try { + await Auth.login(); + } catch (e) { + expect(e).toEqual(error); + expect(closeFn).toHaveBeenCalled(); + } + }); + + test('Must return error: event not supported', async () => { + const opener = { + closed: false, + close: closeFn, + }; + openFn.mockReturnValue(opener); + eventListenerFn.mockImplementation((event, callback) => { + callback({ + origin: 'vk.com', + source: opener, + data: { + payload: {}, + }, + }); + }); + + const error = { + code: AuthErrorCode.EventNotSupported, + text: AUTH_ERROR_TEXT[AuthErrorCode.EventNotSupported], + }; + + try { + await Auth.login(); + } catch (e) { + expect(closeFn).toHaveBeenCalled(); + expect(e).toEqual(error); + } + }); + + test('Must return data', async () => { + const response: AuthResponse = { + token: 'token', + type: 'silent_token', + ttl: 500, + }; + const opener = { + closed: false, + close: closeFn, + }; + openFn.mockReturnValue(opener); + eventListenerFn.mockImplementation((event, callback) => { + callback({ + origin: 'vk.com', + source: opener, + data: { + action: AUTH_VK_CONNECT_RESPONSE, + payload: response, + }, + }); + }); + + try { + const res = await Auth.login(); + expect(closeFn).toHaveBeenCalled(); + expect(res).toEqual(response); + } catch (e) { + } + }); +}); diff --git a/__tests__/auth/authDataService.tests.ts b/__tests__/auth/authDataService.tests.ts new file mode 100644 index 0000000..730f5f6 --- /dev/null +++ b/__tests__/auth/authDataService.tests.ts @@ -0,0 +1,90 @@ +import { AuthDataService } from '#/auth/authDataService'; +import { AUTH_ERROR_TEXT } from '#/auth/constants'; +import { AuthErrorCode } from '#/auth/types'; + +describe('AuthDataService', () => { + test('Must return data on successful completion', async () => { + const dataService = new AuthDataService(); + const successData = { + additionally: 'additionally', + token: 'token', + type: 'type', + ttl: 600, + }; + dataService.sendSuccessData(successData); + + const data = await dataService.value; + expect(data).toEqual({ + token: 'token', + type: 'type', + ttl: 600, + }); + }); + + test('Must return error: new tab has been closed', async () => { + const dataService = new AuthDataService(); + const error = { + code: AuthErrorCode.NewTabHasBeenClosed, + text: AUTH_ERROR_TEXT[AuthErrorCode.NewTabHasBeenClosed], + }; + + dataService.sendNewTabHasBeenClosed(); + + try { + await dataService.value; + } catch (e) { + expect(e).toEqual(error); + } + }); + + test('Must return error: event not supported', async () => { + const dataService = new AuthDataService(); + const error = { + code: AuthErrorCode.EventNotSupported, + text: AUTH_ERROR_TEXT[AuthErrorCode.EventNotSupported], + }; + + dataService.sendEventNotSupported(); + + try { + await dataService.value; + } catch (e) { + expect(e).toEqual(error); + } + }); + + test('Must return error: cannot create new tab', async () => { + const dataService = new AuthDataService(); + const error = { + code: AuthErrorCode.CannotCreateNewTab, + text: AUTH_ERROR_TEXT[AuthErrorCode.CannotCreateNewTab], + }; + + dataService.sendCannotCreateNewTab(); + + try { + await dataService.value; + } catch (e) { + expect(e).toEqual(error); + } + }); + + test('Must return error: authorization failed', async () => { + const dataService = new AuthDataService(); + const additionally = { + additionally: 'additionally', + }; + const error = { + code: AuthErrorCode.AuthorizationFailed, + text: AUTH_ERROR_TEXT[AuthErrorCode.AuthorizationFailed], + }; + + dataService.sendAuthorizationFailed(additionally); + + try { + await dataService.value; + } catch (e) { + expect(e).toEqual({ ...error, details: additionally }); + } + }); +}); diff --git a/__tests__/constants.ts b/__tests__/constants.ts new file mode 100644 index 0000000..8bb463b --- /dev/null +++ b/__tests__/constants.ts @@ -0,0 +1,2 @@ +export const WINDOW_LOCATION_HOST = 'rnd-service.ru'; +export const WINDOW_LOCATION_URL = `https://${WINDOW_LOCATION_HOST}`; diff --git a/__tests__/core/config/config.tests.ts b/__tests__/core/config/config.tests.ts new file mode 100644 index 0000000..512e10d --- /dev/null +++ b/__tests__/core/config/config.tests.ts @@ -0,0 +1,32 @@ +import { Config } from '#/core/config'; + +describe('Config', () => { + it('Should get default properties', () => { + const config = new Config(); + + expect(config.get()).toBeTruthy(); + + expect(config.get().loginDomain).toContain('login.'); + expect(config.get().oauthDomain).toContain('oauth.'); + expect(config.get().vkidDomain).toContain('id.'); + expect(config.get().app).toBe(0); + }); + + it('Should override properties', () => { + const config = new Config(); + const OVERRIDE_STRING = 'Ryoji'; + + config.set({ + app: 100, + loginDomain: OVERRIDE_STRING, + oauthDomain: OVERRIDE_STRING, + vkidDomain: OVERRIDE_STRING, + }); + + expect(config.get()).toBeTruthy(); + expect(config.get().app).toBe(100); + expect(config.get().loginDomain).toBe(OVERRIDE_STRING); + expect(config.get().oauthDomain).toBe(OVERRIDE_STRING); + expect(config.get().vkidDomain).toBe(OVERRIDE_STRING); + }); +}); diff --git a/__tests__/core/dataService/dataService.tests.ts b/__tests__/core/dataService/dataService.tests.ts new file mode 100644 index 0000000..c246aa0 --- /dev/null +++ b/__tests__/core/dataService/dataService.tests.ts @@ -0,0 +1,51 @@ +import { DataService } from '#/core/dataService'; + +describe('DataService', () => { + test('Must return data on successful completion', async () => { + const dataService = new DataService(); + const successData = 'success'; + dataService.sendSuccess(successData); + + const data = await dataService.value; + expect(data).toBe(successData); + }); + + test('Must return error data', async () => { + const dataService = new DataService(); + const errorData = 'error'; + dataService.sendError(errorData); + + try { + await dataService.value; + } catch (e) { + expect(e).toBe(errorData); + } + }); + + test('Must return data on successful completion and execute a callback', async () => { + const dataService = new DataService(); + const successData = 'success'; + const callback = jest.fn(); + dataService.setCallback(callback); + dataService.sendSuccess(successData); + + const data = await dataService.value; + expect(data).toBe(successData); + expect(callback).toBeCalled(); + }); + + test('Must return error data and execute a callback', async () => { + const dataService = new DataService(); + const errorData = 'error'; + const callback = jest.fn(); + dataService.setCallback(callback); + dataService.sendError(errorData); + + try { + await dataService.value; + } catch (e) { + expect(e).toBe(errorData); + expect(callback).toBeCalled(); + } + }); +}); diff --git a/__tests__/core/dispatcher/dispatcher.tests.ts b/__tests__/core/dispatcher/dispatcher.tests.ts new file mode 100644 index 0000000..34ff67e --- /dev/null +++ b/__tests__/core/dispatcher/dispatcher.tests.ts @@ -0,0 +1,69 @@ +import { Dispatcher } from '#/core/dispatcher'; + +class TestDispatcher extends Dispatcher { + public emit(event: string, data: any): void { + this.events.emit(event, data); + } +} + +describe('Dispatcher', () => { + let instance: TestDispatcher; + + beforeEach(() => { + instance = new TestDispatcher(); + }); + + it('should register event handler with on() method', () => { + const event = 'myEvent'; + const handler = jest.fn(); + + const spyOnEventsOn = jest.spyOn(instance, 'on'); + instance.on(event, handler); + + expect(spyOnEventsOn).toHaveBeenCalledWith(event, handler); + }); + + it('should unregister event handler with off() method', () => { + const event = 'myEvent'; + const handler = jest.fn(); + + const spyOnEventsOff = jest.spyOn(instance, 'off'); + instance.off(event, handler); + + expect(spyOnEventsOff).toHaveBeenCalledWith(event, handler); + }); + + it('should return the instance of Dispatcher from on() method', () => { + const event = 'myEvent'; + const handler = jest.fn(); + + const result = instance.on(event, handler); + + expect(result).toBe(instance); + }); + + it('should return the instance of Dispatcher from off() method', () => { + const event = 'myEvent'; + const handler = jest.fn(); + + const result = instance.off(event, handler); + + expect(result).toBe(instance); + }); + + it('should send and handle event', async () => { + const event = 'myEvent'; + const eventData = 'someData'; + + const eventHandledPromise = new Promise((resolve) => { + instance.on(event, (data: any) => { + expect(data).toBe(eventData); + resolve(); + }); + }); + + instance.emit(event, eventData); + + await eventHandledPromise; + }); +}); diff --git a/__tests__/core/validator/validationRule.tests.ts b/__tests__/core/validator/validationRule.tests.ts new file mode 100644 index 0000000..5265ea6 --- /dev/null +++ b/__tests__/core/validator/validationRule.tests.ts @@ -0,0 +1,68 @@ +import { isRequired, isNumber, isValidAppId, isValidHeight } from '#/core/validator'; + +describe('isNumber rule', () => { + it('should return true', () => { + expect(isNumber(1).result).toBeTruthy(); + expect(isNumber(12345).result).toBeTruthy(); + expect(isNumber(-200).result).toBeTruthy(); + expect(isNumber(0).result).toBeTruthy(); + expect(isNumber(1.1).result).toBeTruthy(); + expect(isNumber('123').result).toBeTruthy(); + }); + + it('should return false', () => { + expect(isNumber(undefined).result).toBeFalsy(); + expect(isNumber(null).result).toBeFalsy(); + expect(isNumber(false).result).toBeFalsy(); + expect(isNumber({ '12': 24 }).result).toBeFalsy(); + expect(isNumber([12, 33, 86]).result).toBeFalsy(); + }); +}); + +describe('isRequired rule', () => { + it('should return true', () => { + expect(isRequired(1).result).toBeTruthy(); + expect(isRequired('12345').result).toBeTruthy(); + expect(isRequired(true).result).toBeTruthy(); + expect(isRequired([8, 0, 9, 53]).result).toBeTruthy(); + expect(isRequired({ what: true, time: '15:00' }).result).toBeTruthy(); + }); + + it('should return false', () => { + expect(isRequired('').result).toBeFalsy(); + expect(isRequired(' ').result).toBeFalsy(); + expect(isRequired(undefined).result).toBeFalsy(); + expect(isRequired(null).result).toBeFalsy(); + }); +}); + +describe('isValidAppId rule', () => { + it('should return true', () => { + expect(isValidAppId(undefined).result).toBeTruthy(); + expect(isValidAppId(1).result).toBeTruthy(); + expect(isValidAppId('12345').result).toBeTruthy(); + }); + + it('should return false', () => { + expect(isValidAppId({ what: true, time: '15:00' }).result).toBeFalsy(); + expect(isValidAppId([8, 0, 9, 53]).result).toBeFalsy(); + expect(isValidAppId(true).result).toBeFalsy(); + expect(isValidAppId('').result).toBeFalsy(); + expect(isValidAppId(null).result).toBeFalsy(); + }); +}); + +describe('isValidHeight rule', () => { + it('should return true', () => { + expect(isValidHeight(undefined).result).toBeTruthy(); + expect(isValidHeight({}).result).toBeTruthy(); + expect(isValidHeight({ height: 32 }).result).toBeTruthy(); + expect(isValidHeight({ height: 56 }).result).toBeTruthy(); + }); + + it('should return false', () => { + expect(isValidHeight({ height: '31' }).result).toBeFalsy(); + expect(isValidHeight({ height: 31 }).result).toBeFalsy(); + expect(isValidHeight({ height: 57 }).result).toBeFalsy(); + }); +}); diff --git a/__tests__/core/validator/validator.tests.ts b/__tests__/core/validator/validator.tests.ts new file mode 100644 index 0000000..a02f1a2 --- /dev/null +++ b/__tests__/core/validator/validator.tests.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { isRequired, isNumber, isValidAppId, validator } from '#/core/validator'; + +describe('Validator', () => { + it('one parameter, one rule', () => { + const correctParams = { value1: 'Langley' }; + const incorrectParams = { value1: null }; + + class Class { + @validator({ value1: [isRequired] }) + public static method(user: any) { + return user; + } + } + + expect(Class.method(correctParams)).toBeTruthy(); + expect(() => Class.method(incorrectParams)).toThrow( + 'value1 is required parameter', + ); + }); + + it('one parameter, two rules', () => { + const correctParams = { value1: 2 }; + const incorrectParams = { value1: 'Ayanami' }; + + class Class { + @validator({ value1: [isRequired, isNumber] }) + public static method(user: any) { + return user; + } + } + + expect(Class.method(correctParams)).toBeTruthy(); + expect(() => Class.method(incorrectParams)).toThrow( + 'value1 should be number', + ); + }); + + it('two parameters, one rule', () => { + const correctParams = { value1: 'Ikari', value2: 2 }; + const incorrectParams = { value1: 'Katsuragi', value2: {} }; + + class Class { + @validator({ value1: [isRequired], value2: [isNumber] }) + public static method(user: any) { + return user; + } + } + + expect(Class.method(correctParams)).toBeTruthy(); + expect(() => Class.method(incorrectParams)).toThrow( + 'value2 should be number', + ); + }); + + it('two parameters, two rules', () => { + const correctParams = { value1: 2, value2: 2 }; + const incorrectParams = { value1: 1, value2: -1 }; + + class Class { + @validator({ + value1: [isRequired, isNumber], + value2: [isRequired, isValidAppId], + }) + public static method(user: any) { + return user; + } + } + + expect(Class.method(correctParams)).toBeTruthy(); + expect(() => Class.method(incorrectParams)).toThrow( + 'value2 is not a valid app id', + ); + }); +}); diff --git a/__tests__/core/widget/widget.tests.ts b/__tests__/core/widget/widget.tests.ts new file mode 100644 index 0000000..ca7e9c1 --- /dev/null +++ b/__tests__/core/widget/widget.tests.ts @@ -0,0 +1,110 @@ +import { VERSION } from '#/constants'; +import { BridgeMessage } from '#/core/bridge'; +import { BRIDGE_MESSAGE_TYPE_SDK } from '#/core/bridge/bridge'; +import { Widget, WidgetEvents } from '#/core/widget'; +import { Config } from '#/index'; + +const APP_ID = 100; + +let container: HTMLElement; +let iframeElement: HTMLIFrameElement; + +const onHandlerFn = jest.fn(); + +class TestWidget extends Widget { + public onBridgeMessageHandler(event: BridgeMessage) { + super.onBridgeMessageHandler(event); + } +} + +let widget: TestWidget; + +describe('Data Policy', () => { + beforeEach(() => { + Config.set({ app: APP_ID }); + widget = new TestWidget(); + + container = document.createElement('div', {}); + document.body.append(container); + + widget.render({ container }); + iframeElement = container.querySelector('iframe') as HTMLIFrameElement; + }); + + afterEach(() => { + widget.close(); + container.remove(); + }); + + test('Check iframe url params', () => { + expect(iframeElement).toBeTruthy(); + + const frameSrc = iframeElement.getAttribute('src'); + const urlParams = new URL(frameSrc ?? '').searchParams; + + expect(urlParams.get('v')).toContain(VERSION); + expect(urlParams.get('app_id')).toContain(APP_ID.toString()); + expect(urlParams.get('origin')).toContain(location.protocol + '//' + location.host); + expect(urlParams.get('code_challenge_method')).toContain('s256'); + expect(frameSrc).toContain('id.vk.'); + expect(frameSrc).toContain('uuid'); + }); + + test('Should remove root after close()', () => { + widget.close(); + expect(container.querySelector('iframe') as HTMLIFrameElement).toBeFalsy(); + }); + + test('Should hide root after hide()', () => { + widget.on(WidgetEvents.HIDE, onHandlerFn); + + widget.hide(); + const root = container.querySelector('[data-test-id="widget"]') as HTMLIFrameElement; + + expect(root.style.display).toBe('none'); + expect(onHandlerFn).toBeCalled(); + }); + + test('Should show root after show()', () => { + const root = container.querySelector('[data-test-id="widget"]') as HTMLIFrameElement; + root.style.display = 'none'; + widget.show(); + expect(root.style.display).toBe('block'); + }); + + test('should handle internal CLOSE event and emit public', () => { + widget.on(WidgetEvents.CLOSE, onHandlerFn); + + widget.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: WidgetEvents.CLOSE, + params: {}, + }); + + expect(onHandlerFn).toBeCalled(); + }); + + test('Should handle internal LOAD event and emit public', () => { + widget.on(WidgetEvents.LOAD, onHandlerFn); + + widget.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: WidgetEvents.LOAD, + params: {}, + }); + + expect(onHandlerFn).toBeCalled(); + }); + + test('Should handle internal ERROR event and emit public', () => { + widget.on(WidgetEvents.ERROR, onHandlerFn); + + widget.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: WidgetEvents.ERROR, + params: { msg: 1 }, + }); + + expect(onHandlerFn).toBeCalledWith({ code: 1, text: 'internal error', details: { msg: 1 } }); + }); +}); diff --git a/__tests__/jest-global-mock.js b/__tests__/jest-global-mock.js new file mode 100644 index 0000000..11d6954 --- /dev/null +++ b/__tests__/jest-global-mock.js @@ -0,0 +1,26 @@ +/** + * Mock crypto funcs + */ + +jest.mock('crypto-js/sha256', () => ({ + __esModule: true, + ...jest.requireActual('crypto-js/sha256'), + default: () => 'SHA256-STRING', +})); + +jest.mock('crypto-js/enc-base64', () => ({ + __esModule: true, + ...jest.requireActual('crypto-js/enc-base64'), + default: { stringify: (str) => `stringified_${str}` }, +})); + +/** + * mock ENV + */ +const isProduction = process.env.NODE_ENV === 'production'; +const { version } = require('../package.json'); +window.env = { + PRODUCTION: isProduction, + VERSION: JSON.stringify(version), + DOMAIN: JSON.stringify('vk.com'), +}; diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json new file mode 100644 index 0000000..5197ce2 --- /dev/null +++ b/__tests__/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/__tests__/utils.ts b/__tests__/utils.ts new file mode 100644 index 0000000..7bd32b4 --- /dev/null +++ b/__tests__/utils.ts @@ -0,0 +1,16 @@ +const BRIDGE_MESSAGE_TYPE_SDK = 'vk-sak-sdk'; + +export const dispatchPostMessageEvent = (origin: string, source: Window, data: any) => { + window.dispatchEvent( + new MessageEvent('message', { + origin, + source, + data: { + type: BRIDGE_MESSAGE_TYPE_SDK, + ...data, + }, + }), + ); +}; + +export const wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time)); diff --git a/__tests__/widgets/agreementsDialog/agreementsDialog.tests.ts b/__tests__/widgets/agreementsDialog/agreementsDialog.tests.ts new file mode 100644 index 0000000..50e43af --- /dev/null +++ b/__tests__/widgets/agreementsDialog/agreementsDialog.tests.ts @@ -0,0 +1,77 @@ +import { BridgeMessage } from '#/core/bridge'; +import { BRIDGE_MESSAGE_TYPE_SDK } from '#/core/bridge/bridge'; +import { WidgetEvents } from '#/core/widget'; +import { Config } from '#/index'; +import { AgreementsDialog } from '#/widgets/agreementsDialog'; +import { AgreementsDialogInternalEvents } from '#/widgets/agreementsDialog/events'; + +class TestAgreementsDialog extends AgreementsDialog { + public onBridgeMessageHandler(event: BridgeMessage) { + super.onBridgeMessageHandler(event); + } +} + +const APP_ID = 100; + +let iframeElement: HTMLIFrameElement; +let agreementsDialog: TestAgreementsDialog; + +describe('Agreements Dialog', () => { + beforeEach(() => { + Config.set({ app: APP_ID }); + agreementsDialog = new TestAgreementsDialog(); + + agreementsDialog.render({ container: document.body }); + iframeElement = document.querySelector('iframe') as HTMLIFrameElement; + }); + + afterEach(() => { + agreementsDialog.close(); + }); + + test('check iframe url params', () => { + expect(iframeElement).toBeTruthy(); + + const frameSrc = iframeElement.getAttribute('src') as string; + const location = frameSrc.split(/[?&]/); + + const expectArr = [ + expect(location[0]).toEqual('https://id.vk.com/user_policy_agreements'), + expect(location[1]).toContain('code_challenge=stringified_SHA256-STRING'), + expect(location[2]).toContain('code_challenge_method=s256'), + expect(location[3]).toContain('origin=https%3A%2F%2Frnd-service.ru'), + expect(frameSrc).toContain('uuid'), + expect(frameSrc).toContain('v'), + expect(location[6]).toContain('app_id=100'), + expect(location[7]).toContain('sdk_type=vkid'), + ]; + + expect(location.length).toEqual(expectArr.length); + }); + + test('Must close the iframe on the decline event', () => { + expect(iframeElement).toBeTruthy(); + + agreementsDialog.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: AgreementsDialogInternalEvents.DECLINE, + params: { uuid: 'uuid' }, + }); + + expect(document.querySelector('iframe')).toBeFalsy(); + }); + + test('Should emit public ACCEPT after internal', () => { + expect(iframeElement).toBeTruthy(); + + const handler = jest.fn(); + agreementsDialog.on(AgreementsDialogInternalEvents.ACCEPT, handler); + agreementsDialog.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: AgreementsDialogInternalEvents.ACCEPT, + params: { uuid: 'uuid' }, + }); + + expect(handler).toBeCalled(); + }); +}); diff --git a/__tests__/widgets/captcha/captcha.tests.ts b/__tests__/widgets/captcha/captcha.tests.ts new file mode 100644 index 0000000..b9bf1ce --- /dev/null +++ b/__tests__/widgets/captcha/captcha.tests.ts @@ -0,0 +1,33 @@ +import { Config } from '#/index'; +import { Captcha } from '#/widgets/captcha'; + +const APP_ID = 100; + +let container: HTMLElement; +let iframeElement: HTMLIFrameElement; + +let captcha: Captcha; + +describe('Captcha', () => { + beforeEach(() => { + Config.set({ app: APP_ID }); + captcha = new Captcha(); + + container = document.createElement('div', {}); + document.body.append(container); + + captcha.render({ container }); + iframeElement = container.querySelector('iframe') as HTMLIFrameElement; + }); + + afterEach(() => { + captcha.close(); + container.remove(); + }); + + test('check iframe url params', () => { + expect(iframeElement).toBeTruthy(); + const frameSrc = iframeElement.getAttribute('src'); + expect(frameSrc).toContain('auth_captcha'); + }); +}); diff --git a/__tests__/widgets/dataPolicy/dataPolicy.tests.ts b/__tests__/widgets/dataPolicy/dataPolicy.tests.ts new file mode 100644 index 0000000..821ffcb --- /dev/null +++ b/__tests__/widgets/dataPolicy/dataPolicy.tests.ts @@ -0,0 +1,33 @@ +import { Config } from '#/index'; +import { DataPolicy } from '#/widgets/dataPolicy'; + +const APP_ID = 100; + +let container: HTMLElement; +let iframeElement: HTMLIFrameElement; + +let dataPolicy: DataPolicy; + +describe('Data Policy', () => { + beforeEach(() => { + Config.set({ app: APP_ID }); + dataPolicy = new DataPolicy(); + + container = document.createElement('div', {}); + document.body.append(container); + + dataPolicy.render({ container }); + iframeElement = container.querySelector('iframe') as HTMLIFrameElement; + }); + + afterEach(() => { + dataPolicy.close(); + container.remove(); + }); + + test('check iframe url params', () => { + expect(iframeElement).toBeTruthy(); + const frameSrc = iframeElement.getAttribute('src'); + expect(frameSrc).toContain('user_data_policy'); + }); +}); diff --git a/__tests__/widgets/oneTap/oneTap.tests.ts b/__tests__/widgets/oneTap/oneTap.tests.ts new file mode 100644 index 0000000..0aeb064 --- /dev/null +++ b/__tests__/widgets/oneTap/oneTap.tests.ts @@ -0,0 +1,190 @@ +import { BridgeMessage } from '#/core/bridge'; +import { BRIDGE_MESSAGE_TYPE_SDK } from '#/core/bridge/bridge'; +import { WidgetEvents } from '#/core/widget'; +import { Config } from '#/index'; +import { OneTap } from '#/widgets/oneTap'; +import { OneTapInternalEvents, OneTapPublicEvents } from '#/widgets/oneTap/events'; + +const APP_ID = 100; + +let container: HTMLElement; +let iframeElement: HTMLIFrameElement; +let oneTap: TestOneTap; + +const onHandlerFn = jest.fn(); +const openFn = jest.fn(); +const removeEventListenerFn = jest.fn(); + +class TestOneTap extends OneTap { + public onBridgeMessageHandler(event: BridgeMessage) { + super.onBridgeMessageHandler(event); + } +} + +describe('OneTap', () => { + beforeAll(() => { + window.open = openFn; + window.addEventListener = jest.fn().mockImplementation((event, callback) => { + if (event === 'DOMContentLoaded') { + setTimeout(callback, 0); + } + }); + window.removeEventListener = removeEventListenerFn; + }); + + beforeEach(() => { + Config.set({ app: APP_ID }); + oneTap = new TestOneTap(); + + container = document.createElement('div', {}); + document.body.append(container); + }); + + afterEach(() => { + oneTap.close(); + container.remove(); + }); + + test('Check iframe url params', () => { + oneTap.render({ container, showAlternativeLogin: true }); + iframeElement = container.querySelector('iframe') as HTMLIFrameElement; + + expect(iframeElement).toBeTruthy(); + + const frameSrc = iframeElement.getAttribute('src') as string; + const location = frameSrc.split(/[?&]/); + + const expectArr = [ + expect(location[0]).toEqual('https://id.vk.com/button_one_tap_auth'), + expect(location[1]).toEqual('style_height=44'), + expect(location[2]).toEqual('style_border_radius=8'), + expect(location[3]).toEqual('show_alternative_login=1'), + expect(location[4]).toEqual('button_skin=primary'), + expect(location[5]).toEqual('scheme=light'), + expect(location[6]).toEqual('code_challenge=stringified_SHA256-STRING'), + expect(location[7]).toEqual('code_challenge_method=s256'), + expect(location[8]).toEqual('origin=https%3A%2F%2Frnd-service.ru'), + expect(frameSrc).toContain('uuid'), + expect(frameSrc).toContain('v'), + expect(location[11]).toEqual('app_id=100'), + expect(location[12]).toEqual('sdk_type=vkid'), + ]; + + expect(location.length).toEqual(expectArr.length); + }); + + test('Should use the light theme and the main default skin', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + }); + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-scheme')).toEqual('light'); + expect(oneTapEl?.getAttribute('data-skin')).toEqual('primary'); + }); + + test('Should use a light theme and a secondary skin', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + skin: 'secondary', + }); + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-scheme')).toEqual('light'); + expect(oneTapEl?.getAttribute('data-skin')).toEqual('secondary'); + }); + + test('Should use a dark theme and a primary skin', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + scheme: 'dark', + }); + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-scheme')).toEqual('dark'); + expect(oneTapEl?.getAttribute('data-skin')).toEqual('primary'); + }); + + test('Should use a dark theme and a secondary skin', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + scheme: 'dark', + skin: 'secondary', + }); + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-scheme')).toEqual('dark'); + expect(oneTapEl?.getAttribute('data-skin')).toEqual('secondary'); + }); + + test('Must be in the loading state', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + }); + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-state')).toEqual('loading'); + }); + + test('Must be in not_loaded state', () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + }); + + oneTap.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: WidgetEvents.ERROR, + params: { someData: 'token' }, + }); + + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + expect(oneTapEl?.getAttribute('data-state')).toEqual('not_loaded'); + }); + + test('Must be in a state of loaded', async () => { + oneTap.render({ + container, + showAlternativeLogin: 1, + }); + + oneTap.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: WidgetEvents.LOAD, + params: { someData: 'token' }, + }); + + const oneTapEl = document.querySelector('[data-test-id="oneTap"]'); + await new Promise((resolve) => { + setTimeout(() => { + resolve(''); + }, 400); + }); + expect(oneTapEl?.getAttribute('data-state')).toEqual('loaded'); + }); + + test('Should emit public LOGIN_SUCCESS after internal', () => { + oneTap.on(OneTapPublicEvents.LOGIN_SUCCESS, onHandlerFn); + + oneTap.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: OneTapInternalEvents.LOGIN_SUCCESS, + params: { someData: 'token' }, + }); + + expect(onHandlerFn).toBeCalled(); + }); + + test('Should emit public SHOW_FULL_AUTH after internal', () => { + oneTap.on(OneTapPublicEvents.SHOW_FULL_AUTH, onHandlerFn); + + oneTap.onBridgeMessageHandler({ + type: BRIDGE_MESSAGE_TYPE_SDK, + handler: OneTapInternalEvents.SHOW_FULL_AUTH, + params: {}, + }); + + expect(onHandlerFn).toBeCalled(); + expect(openFn).toHaveBeenCalled(); + }); +}); diff --git a/demo/components/snackbar.ts b/demo/components/snackbar.ts new file mode 100644 index 0000000..b77086b --- /dev/null +++ b/demo/components/snackbar.ts @@ -0,0 +1,64 @@ +type SnackbarType = 'success' | 'error'; + +let isActiveSnackbar = false; + +const snackbarText: Record = { + 'success': 'Успешная авторизация', + 'error': 'Ошибка авторизации', +}; + +const snackbarIcon: Record = { + 'success': ` + + `, + 'error': ` + + `, +}; + +const showSnackbar = (type: SnackbarType) => { + if (!isActiveSnackbar) { + isActiveSnackbar = true; + const defaultStyle = `VkIdWebSdk__snackbar VkIdWebSdk__snackbar_${type}`; + const snackbar = document.createElement('div'); + snackbar.className = defaultStyle; + snackbar.innerHTML = `${snackbarIcon[type]} ${snackbarText[type]}`; + + setTimeout(() => { + snackbar.className += ' VkIdWebSdk__snackbar_active'; + }, 100); + + setTimeout(() => { + snackbar.className = defaultStyle; + }, 3000); + + setTimeout(() => { + snackbar.remove(); + isActiveSnackbar = false; + }, 3400); + + document.body.appendChild(snackbar); + } +}; + +export const showSuccessSnackbar = () => { + showSnackbar('success'); +}; + +export const showErrorSnackbar = () => { + showSnackbar('error'); +}; diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..8fe4ef6 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,59 @@ + + + + + + + + + VK ID WEB SDK + + +
+
+

Auth

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +

OneTap Widget

+
+
+
+ + diff --git a/demo/index.ts b/demo/index.ts new file mode 100644 index 0000000..afaf579 --- /dev/null +++ b/demo/index.ts @@ -0,0 +1,47 @@ +import './styles.css'; +import * as VKID from '@vkid/sdk'; +import { OneTap } from '@vkid/sdk/widgets/oneTap'; +import { OneTapPublicEvents } from '@vkid/sdk/widgets/oneTap/events'; + +import { showSuccessSnackbar, showErrorSnackbar } from '#demo/components/snackbar'; + +const authButtonIds = ['authIconButton', 'authButton', 'authButtonWithIcon']; + +VKID.Config.set({ app: 7303035 }); + +const handleSuccess = (response: any) => { + console.info(response); + + if (response.token) { + showSuccessSnackbar(); + } else { + showErrorSnackbar(); + } +}; + +const handleError = (error: any) => { + console.error(error); + showErrorSnackbar(); +}; + +const handleClick = () => { + VKID.Auth.login() + .then(handleSuccess) + .catch(handleError); +}; + +authButtonIds.forEach((item) => { + const button = document.getElementById(item); + + button && (button.onclick = handleClick); +}); + +const oneTapEl = document.getElementById('oneTap') as HTMLElement; +const oneTapSuccess = (response: any) => { + console.info(response); + showSuccessSnackbar(); +}; +const oneTap = new OneTap(); +oneTap.on(OneTapPublicEvents.LOGIN_SUCCESS, oneTapSuccess); +oneTap.on(OneTapPublicEvents.LOGIN_FAILED, showErrorSnackbar); +oneTap.render({ container: oneTapEl, showAlternativeLogin: true }); diff --git a/demo/styles.css b/demo/styles.css new file mode 100644 index 0000000..07d2ab2 --- /dev/null +++ b/demo/styles.css @@ -0,0 +1,29 @@ +@import url('./styles/variables.css'); +@import url('./styles/button.css'); +@import url('./styles/snackbar.css'); + +html, body { + margin: 0; + padding: 0; + background: var(--background_page); +} + +.VkIdWebSdk__container { + display: flex; + justify-content: center; + padding: 30px; +} + +.VkIdWebSdk__content { + max-width: 650px; + width: 650px; + border-radius: 8px; +} + +.VkIdWebSdk__content > *:not(:first-child) { + margin: 24px 0; +} + +.VkIdWebSdk__content > h1 { + font-family: -apple-system, system-ui, "Helvetica Neue", Roboto, sans-serif; +} \ No newline at end of file diff --git a/demo/styles/button.css b/demo/styles/button.css new file mode 100644 index 0000000..4d0fcb4 --- /dev/null +++ b/demo/styles/button.css @@ -0,0 +1,73 @@ +.VkIdWebSdk__button_reset, +.VkIdWebSdk__iconButton_reset { + border: none; + margin: 0; + padding: 0; + width: auto; + overflow: visible; + background: transparent; + color: inherit; + font: inherit; + line-height: normal; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + -webkit-appearance: none; +} + +.VkIdWebSdk__button, +.VkIdWebSdk__iconButton { + background: var(--accent_alternate); + cursor: pointer; + transition: all .1s ease-out; +} + +.VkIdWebSdk__button:hover, +.VkIdWebSdk__iconButton:hover { + opacity: 0.8; +} + +.VkIdWebSdk__button:active, +.VkIdWebSdk__iconButton:active { + opacity: .7; + transform: scale(.97); +} + +.VkIdWebSdk__button { + border-radius: 8px; + width: 100%; + min-height: 44px; +} + +.VkIdWebSdk__button_container { + display: flex; + align-items: center; + padding: 8px 10px; +} + +.VkIdWebSdk__iconButton { + display: flex; + justify-content: center; + align-items: center; + padding: 10px; + border-radius: 10px; +} + +.VkIdWebSdk__iconButton_icon { + display: flex; +} + +.VkIdWebSdk__button_icon, +.VkIdWebSdk__button_text { + display: flex; +} + +.VkIdWebSdk__button_icon + .VkIdWebSdk__button_text { + margin-left: -28px; +} + +.VkIdWebSdk__button_text { + font-family: -apple-system, system-ui, "Helvetica Neue", Roboto, sans-serif; + flex: 1; + justify-content: center; + color: var(--vkui-light-text-text-contrast); +} diff --git a/demo/styles/snackbar.css b/demo/styles/snackbar.css new file mode 100644 index 0000000..18b6260 --- /dev/null +++ b/demo/styles/snackbar.css @@ -0,0 +1,27 @@ +.VkIdWebSdk__snackbar { + display: flex; + align-items: center; + min-width: 351px; + padding: 16px 16px 16px 56px; + box-sizing: border-box; + background: var(--vkui--color_background_content); + border-radius: var(--vkui--size_border_radius--regular); + box-shadow: var(--vkui--elevation4); + font-family: -apple-system, system-ui, "Helvetica Neue", Roboto, sans-serif; + position: fixed; + bottom: 24px; + left: 0; + transform: translate(-100%, 0); + transition: transform 320ms var(--vkui--animation_easing_platform); +} + +.VkIdWebSdk__snackbar_active { + transform: translate(24px, 0); +} + +.VkIdWebSdk__snackbar_icon { + position: absolute; + left: 16px; + width: 28px; + height: 28px; +} diff --git a/demo/styles/variables.css b/demo/styles/variables.css new file mode 100644 index 0000000..cba72b4 --- /dev/null +++ b/demo/styles/variables.css @@ -0,0 +1,17 @@ +:root { + --accent_alternate: #0077ff; + + --vkui--color_background_accent: #2688EB; + --vkui--color_background_accent--hover: #2483E4; + --vkui--color_background_accent--active: #237EDD; + --vkui--color_background_secondary: #f5f5f5; + --vkui--color_background_content: #ffffff; + --vkui-light-text-text-contrast: #ffffff; + + --vkui--size_border_radius--regular: 8px; + --vkui--animation_easing_platform: cubic-bezier(0.4,0,0.2,1); + --vkui--elevation4: 0px 0px 8px rgba(0,0,0,.12),0px 16px 16px rgba(0,0,0,.16); + + --background_page: #ebedf0; + --light-button-primary-foreground: #FFF; +} diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..52a5ec6 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,211 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +import { WINDOW_LOCATION_URL } from './__tests__/constants'; + +export default { + // All imported modules in your tests should be mocked automatically + automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/lg/4syhny_16gn_jr_sk1tdb4mc0000gp/T/jest_dy", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "dist-coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + // coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // extensionsToTreatAsEsm: ['.ts'], + // globals: { + // 'ts-jest': { + // useESM: true, + // }, + // }, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. + // E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. + // maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + moduleDirectories: ['node_modules', 'src'], + + // An array of file extensions your modules use + moduleFileExtensions: ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'node'], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + '^#/(.*)$': '/src/$1', + 'mitt': '/node_modules/mitt/src', + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: 'ts-jest', + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: '', + + // A list of paths to directories that Jest should use to search for files in + // roots: [], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + setupFiles: ['/__tests__/jest-global-mock.js'], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ["jest-allure/dist/setup"], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: 'jsdom', + + // Options that will be passed to the testEnvironment + testEnvironmentOptions: { + url: WINDOW_LOCATION_URL, + }, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + '**/__tests__/**/*.tests.ts', + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // '/node_modules/', + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + testRunner: "jest-jasmine2", + + // A map from regular expressions to paths to transformers + transform: { + '\\.[jt]sx?$': ['@swc/jest', { + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true + }, + "transform": { + "hidden": { + "jest": true + } + }, + }, + }], + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + transformIgnorePatterns: [ + // '/node_modules/', + // "\\.pnp\\.[^\\/]+$" + ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f42b8c --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "@vkid/sdk", + "version": "0.0.1", + "description": "VK ID Web SDK", + "main": "dist-sdk/cjs/index.js", + "module": "dist-sdk/esm/index.js", + "types": "dist-sdk/types/index.d.ts", + "scripts": { + "sdk:types": "tspc --emitDeclarationOnly -p tsconfig.sdk.json", + "sdk:base": "rollup -c rollup.sdk.config.js --bundleConfigAsCjs", + "sdk:dev": "yarn sdk:base -w", + "sdk:prod": "yarn clear:sdk && cross-env NODE_ENV=production yarn sdk:base && yarn sdk:types", + "docs:dev": "typedoc --plugin typedoc-plugin-markdown --watch --out ./.wiki", + "docs:prod": "yarn clear:docs && typedoc --plugin typedoc-plugin-markdown --out ./.wiki", + "lint:check:ts": "eslint --cache \"./**/*.{ts,tsx}\"", + "tests": "jest --coverage=true", + "clear:sdk": "rimraf dist-sdk", + "clear:demo": "rimraf dist-demo", + "clear:docs": "rimraf ./.wiki", + "demo:base": "rollup -c rollup.demo.config.js --bundleConfigAsCjs", + "demo:dev": "yarn demo:base -w", + "demo:prod": "yarn clear:demo && cross-env NODE_ENV=production yarn demo:base", + "lint-staged": "lint-staged", + "prepare": "husky install", + "release": "yarn publish --non-interactive --access public", + "release:alpha": "yarn release --tag alpha", + "release:beta": "yarn release --tag beta" + }, + "files": [ + "dist-sdk", + ".wiki", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "CODE_STYLE.md", + "LICENSE" + ], + "lint-staged": { + "src/**/*.{ts,tsx}": "yarn lint:check:ts", + "demo/**/*.{ts,tsx}": "yarn lint:check:ts" + }, + "author": "VK ID ", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.4", + "@rollup/plugin-node-resolve": "^15.2.1", + "@rollup/plugin-replace": "^5.0.2", + "@swc/core": "^1.3.82", + "@swc/jest": "^0.2.28", + "@types/crypto-js": "^4.1.1", + "@types/jest": "^28.1.6", + "@types/node": "^16.11.7", + "@typescript-eslint/eslint-plugin": "5.0.0", + "@typescript-eslint/parser": "5.0.0", + "@vkontakte/eslint-config": "3.0.0", + "concurrently": "^8.0.1", + "cross-env": "^7.0.2", + "css-loader": "^6.8.1", + "eslint": "^8.41.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-storybook": "^0.6.12", + "husky": "^8.0.3", + "jest": "^28.1.3", + "jest-allure": "^0.1.3", + "jest-environment-jsdom": "^28.1.3", + "jest-fetch-mock": "^3.0.3", + "jest-jasmine2": "^29.6.2", + "lint-staged": "12.5.0", + "mitt": "^1.2.0", + "nanoid": "^3.0.2", + "resize-observer-polyfill": "^1.5.1", + "rimraf": "3.0.2", + "rollup": "^3.28.1", + "rollup-plugin-generate-html-template": "^1.7.0", + "rollup-plugin-livereload": "^2.0.5", + "rollup-plugin-rename-node-modules": "^1.3.1", + "rollup-plugin-serve": "^2.0.2", + "rollup-plugin-styles": "^4.0.0", + "rollup-plugin-swc3": "^0.10.1", + "rollup-plugin-watch-assets": "^1.0.1", + "style-loader": "^3.3.3", + "ts-jest": "^28.0.7", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "ts-patch": "^3.0.2", + "typedoc": "^0.24.7", + "typedoc-plugin-markdown": "^3.15.3", + "typescript": "5.0.4", + "typescript-transform-paths": "^3.4.6" + }, + "dependencies": { + "@vkontakte/vkjs": "^0.20.0", + "crypto-js": "^4.1.1" + }, + "directories": { + "test": "__tests__" + } +} diff --git a/rollup.demo.config.js b/rollup.demo.config.js new file mode 100644 index 0000000..5494ab9 --- /dev/null +++ b/rollup.demo.config.js @@ -0,0 +1,66 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import replace from '@rollup/plugin-replace'; +import { version } from './package.json'; +import { swc } from 'rollup-plugin-swc3'; +import styles from 'rollup-plugin-styles'; +import html from 'rollup-plugin-generate-html-template'; +import serve from 'rollup-plugin-serve'; +import livereload from 'rollup-plugin-livereload'; +import watchAssets from 'rollup-plugin-watch-assets'; + +const extensions = ['.js', '.ts']; +const isProduction = process.env.NODE_ENV === 'production'; +const entry = 'demo/index.ts'; + +const plugins = [ + styles({ + mode: 'extract' + }), + resolve({ + extensions, + browser: true, + }), + commonjs(), + replace({ + exclude: 'node_modules/**', + values: { + 'env.VERSION': JSON.stringify(version), + 'env.PRODUCTION': isProduction, + }, + preventAssignment: true, + }), + swc({ + minify: isProduction + }), + html({ + template: 'demo/index.html', + target: 'index.html' + }), +]; + +if (!isProduction) { + const devPlugins = [ + serve({ + open: true, + contentBase: ['dist-demo'], + port: 80 + }), + livereload(), + watchAssets({ + assets: ['demo/index.html'] + }), + ]; + plugins.push(...devPlugins); +} + +export default [{ + input: { + index: entry, + }, + output: { + dir: 'dist-demo', + format: 'cjs' + }, + plugins +}]; diff --git a/rollup.sdk.config.js b/rollup.sdk.config.js new file mode 100644 index 0000000..d3a8832 --- /dev/null +++ b/rollup.sdk.config.js @@ -0,0 +1,83 @@ +import { swc } from 'rollup-plugin-swc3'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import replace from '@rollup/plugin-replace'; +import renameNodeModules from 'rollup-plugin-rename-node-modules'; +import { version } from './package.json'; + +const extensions = ['.js', '.ts']; +const isProduction = process.env.NODE_ENV === 'production'; +const entry = 'src/index.ts'; +const external = [ + 'crypto-js/enc-base64', + 'crypto-js/sha256', + '@vkontakte/vkjs' +] + +const plugins = [ + resolve({ + extensions, + browser: true, + }), + renameNodeModules('lib', false), + commonjs(), + replace({ + exclude: 'node_modules/**', + values: { + 'env.VERSION': JSON.stringify(version), + 'env.PRODUCTION': isProduction, + }, + preventAssignment: true, + }), +]; + +const esm = { + input: { + index: entry, + }, + external, + plugins: [ + ...plugins, + swc({ exclude: '', jsc: { target: "esnext" } }), + ], + output: { + dir: 'dist-sdk/esm', + format: 'esm', + preserveModules: true, + preserveModulesRoot: 'src' + } +}; + +const cjs = { + input: { + index: entry, + }, + external, + plugins: [ + ...plugins, + swc() + ], + output: { + dir: 'dist-sdk/cjs', + format: 'cjs', + preserveModules: true, + preserveModulesRoot: 'src' + } +}; + +const umd = { + input: { + index: entry, + }, + plugins: [ + ...plugins, + swc({ minify: false }) + ], + output: { + dir: 'dist-sdk/umd', + format: 'umd', + name: 'VKIDSDK', + } +} + +export default [esm, cjs, umd]; diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..38697ac --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,85 @@ +import { Config } from '#/core/config'; +import { isDomainAllowed } from '#/utils/domain'; +import { getVKIDUrl } from '#/utils/url'; + +import { AuthDataService } from './authDataService'; +import { AUTH_RESPONSE_TOKEN, AUTH_VK_CONNECT_RESPONSE } from './constants'; +import { AuthParams, AuthResponse } from './types'; + +export class Auth { + public static __config: Config; + + private readonly config: Config; + private dataService: AuthDataService; + + private opener: Window | null; + private interval: number; + + public constructor() { + this.config = Auth.__config; + } + + private readonly close = () => { + this.opener && this.opener.close(); + } + + private readonly handleMessage = ({ origin, source, data }: MessageEvent) => { + if (source !== this.opener || !this.opener || !isDomainAllowed(origin)) { + return; + } + + this.unsubscribe(); + + if (data.payload.error) { + this.dataService.sendAuthorizationFailed(data.payload.error); + return; + } + + if (data.action === AUTH_VK_CONNECT_RESPONSE) { + this.dataService.sendSuccessData(data.payload); + return; + } + + this.dataService.sendEventNotSupported(); + } + + private readonly handleInterval = () => { + if (this.opener?.closed) { + this.unsubscribe(); + this.dataService.sendNewTabHasBeenClosed(); + } + }; + + private readonly subscribe = () => { + this.interval = window.setInterval(this.handleInterval, 1000); + window.addEventListener('message', this.handleMessage); + this.dataService.removeCallback(); + } + + private readonly unsubscribe = () => { + window.removeEventListener('message', this.handleMessage); + clearInterval(this.interval); + this.dataService.setCallback(this.close); + } + + public readonly login = (params?: AuthParams): Promise => { + this.dataService = new AuthDataService(); + const queryParams: Partial> = { + scheme: params?.scheme, + lang_id: params?.lang, + origin: location.protocol + '//' + location.hostname, + response_type: AUTH_RESPONSE_TOKEN, + }; + + const url = getVKIDUrl('auth', queryParams, this.config.get()); + this.opener = window.open(url, '_blank'); + + if (this.opener) { + this.subscribe(); + } else { + this.dataService.sendCannotCreateNewTab(); + } + + return this.dataService.value; + } +} diff --git a/src/auth/authDataService.ts b/src/auth/authDataService.ts new file mode 100644 index 0000000..e34ec1a --- /dev/null +++ b/src/auth/authDataService.ts @@ -0,0 +1,43 @@ +import { DataService } from '#/core/dataService'; + +import { AUTH_ERROR_TEXT } from './constants'; +import { AuthError, AuthErrorCode, AuthResponse } from './types'; + +export class AuthDataService extends DataService { + public readonly sendSuccessData = (payload: any) => { + this.sendSuccess({ + type: payload.type, + token: payload.token, + ttl: payload.ttl, + }); + } + + public readonly sendNewTabHasBeenClosed = () => { + this.sendError({ + code: AuthErrorCode.NewTabHasBeenClosed, + text: AUTH_ERROR_TEXT[AuthErrorCode.NewTabHasBeenClosed], + }); + } + + public readonly sendAuthorizationFailed = (details: any) => { + this.sendError({ + code: AuthErrorCode.AuthorizationFailed, + text: AUTH_ERROR_TEXT[AuthErrorCode.AuthorizationFailed], + details, + }); + } + + public readonly sendEventNotSupported = () => { + this.sendError({ + code: AuthErrorCode.EventNotSupported, + text: AUTH_ERROR_TEXT[AuthErrorCode.EventNotSupported], + }); + } + + public readonly sendCannotCreateNewTab = () => { + this.sendError({ + code: AuthErrorCode.CannotCreateNewTab, + text: AUTH_ERROR_TEXT[AuthErrorCode.CannotCreateNewTab], + }); + } +} diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 0000000..3c4fdfa --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,12 @@ +import { AuthErrorCode, AuthErrorText, AuthToken } from '#/auth/types'; + +export const AUTH_RESPONSE_TOKEN: AuthToken = 'silent_token'; + +export const AUTH_ERROR_TEXT: AuthErrorText = { + [AuthErrorCode.EventNotSupported]: 'Event is not supported', + [AuthErrorCode.CannotCreateNewTab]: 'Cannot create new tab. Try checking your browser settings', + [AuthErrorCode.NewTabHasBeenClosed]: 'New tab has been closed', + [AuthErrorCode.AuthorizationFailed]: 'Authorization failed with an error', +}; + +export const AUTH_VK_CONNECT_RESPONSE = 'vk_connect_response'; diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..2f82598 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,2 @@ +export { Auth } from './auth'; +export type { AuthParams, AuthError, AuthResponse, AuthErrorCode } from './types'; diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..42c88f1 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,78 @@ +import { Languages } from '#/types'; + +export interface AuthParams { + /** + * Цветовая тема, в которой будет отображена страница авторизации + */ + scheme?: 'bright_light' | 'space_gray'; + + /** + * Локализация, в которой будет отображена страница авторизации + */ + lang?: Languages; + + /** + * Дополнительные параметры + */ + [key: string]: any; +} + +export enum AuthErrorCode { + /** + * Неизвестное событие + */ + EventNotSupported = 100, + + /** + * Новая вкладка не создалась + */ + CannotCreateNewTab = 101, + + /** + * Новая вкладка была закрыта + */ + NewTabHasBeenClosed = 102, + + /** + * Авторизация завершилась ошибкой + */ + AuthorizationFailed = 103, +} + +export type AuthErrorText = Record; + +export interface AuthError { + /** + * Код ошибки + */ + code: AuthErrorCode; + + /** + * Текст ошибки + */ + text: string; + + /** + * Расширенная информация об ошибке + */ + details?: any; +} + +export type AuthToken = 'silent_token'; + +export interface AuthResponse { + /** + * Токен, полученный после прохождения авторизации + */ + token: string; + + /** + * Вид токена + */ + type: AuthToken; + + /** + * Время жизни токена + */ + ttl: number; +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..849d092 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,13 @@ +// @ts-ignore-next-line пробрасываем версию из package.json в rollup.config +export const VERSION: string = env.VERSION; +// @ts-ignore-next-line пробрасываем тип сборки из rollup.config +export const PRODUCTION = env.PRODUCTION; +// @ts-ignore-next-line пробрасываем тип сборки из rollup.config +export const DOMAIN = 'vk.com'; + +export const LOGIN_DOMAIN = `login.${DOMAIN}`; +export const OAUTH_DOMAIN = `oauth.${DOMAIN}`; +export const VKID_DOMAIN = `id.${DOMAIN}`; +export const ALLOWED_DOMAINS = ['vk.com', 'vk.ru']; +export const DEFAULT_DOMAIN = 'vk.com'; +export const DOMAIN_FILE_URL = 'https://vk.ru/domain.txt'; diff --git a/src/core/bridge/bridge.ts b/src/core/bridge/bridge.ts new file mode 100644 index 0000000..0a6ac18 --- /dev/null +++ b/src/core/bridge/bridge.ts @@ -0,0 +1,49 @@ +import { Dispatcher } from '#/core/dispatcher'; + +import { BridgeMessageData, BridgeConfig, BridgeEvents } from './types'; + +export const BRIDGE_MESSAGE_TYPE_SDK = 'vk-sak-sdk'; + +export class Bridge extends Dispatcher { + private config: BridgeConfig; + + public constructor(config: BridgeConfig) { + super(); + this.config = config; + this.handleMessage = this.handleMessage.bind(this); + // eslint-disable-next-line + window.addEventListener('message', this.handleMessage); + } + + public destroy(): void { + /* Clear references for memory */ + // @ts-ignore-next-line Удаление происходит при десктруктуризации бриджа, поэтому это безопасно. + delete this.config; + // eslint-disable-next-line + window.removeEventListener('message', this.handleMessage); + } + + public sendMessage(message: BridgeMessageData): void { + (this.config.iframe.contentWindow as Window)?.postMessage( + { + type: BRIDGE_MESSAGE_TYPE_SDK, + ...message, + }, + this.config.origin, + ); + } + + private handleMessage(event: MessageEvent): void { + const isUnsupportedMessage = !this.config.origin || + event.origin !== this.config.origin || + event.source !== this.config.iframe.contentWindow || + event.data?.type !== BRIDGE_MESSAGE_TYPE_SDK; + + if (isUnsupportedMessage) { + this.events.emit(BridgeEvents.UNSUPPORTED_MESSAGE, event.data); + return; + } + + this.events.emit(BridgeEvents.MESSAGE, event.data); + } +} diff --git a/src/core/bridge/index.ts b/src/core/bridge/index.ts new file mode 100644 index 0000000..26a9011 --- /dev/null +++ b/src/core/bridge/index.ts @@ -0,0 +1,3 @@ +export { Bridge } from './bridge'; +export type { BridgeMessageData, BridgeMessage, BridgeConfig } from './types'; +export { BridgeEvents } from './types'; diff --git a/src/core/bridge/types.ts b/src/core/bridge/types.ts new file mode 100644 index 0000000..d3bf461 --- /dev/null +++ b/src/core/bridge/types.ts @@ -0,0 +1,18 @@ +export enum BridgeEvents { + MESSAGE = 'message', + UNSUPPORTED_MESSAGE = 'unsupported_message', +} + +export interface BridgeMessageData { + handler: H; + params: Record; +} + +export interface BridgeMessage extends BridgeMessageData { + type: string; +} + +export interface BridgeConfig { + iframe: HTMLIFrameElement; + origin: string; +} diff --git a/src/core/config/config.ts b/src/core/config/config.ts new file mode 100644 index 0000000..4f36037 --- /dev/null +++ b/src/core/config/config.ts @@ -0,0 +1,22 @@ +import { LOGIN_DOMAIN, OAUTH_DOMAIN, VKID_DOMAIN } from '#/constants'; + +import { ConfigData } from './types'; + +export class Config { + private store: ConfigData = { + app: 0, + loginDomain: LOGIN_DOMAIN, + oauthDomain: OAUTH_DOMAIN, + vkidDomain: VKID_DOMAIN, + }; + + public set(config: Partial): this { + this.store = { ...this.store, ...config }; + + return this; + } + + public get(): ConfigData { + return this.store; + } +} diff --git a/src/core/config/index.ts b/src/core/config/index.ts new file mode 100644 index 0000000..3998972 --- /dev/null +++ b/src/core/config/index.ts @@ -0,0 +1,2 @@ +export { Config } from './config'; +export type { ConfigData } from './types'; diff --git a/src/core/config/types.ts b/src/core/config/types.ts new file mode 100644 index 0000000..b847f9e --- /dev/null +++ b/src/core/config/types.ts @@ -0,0 +1,10 @@ +export interface ConfigData { + app: number; + + loginDomain: string; + oauthDomain: string; + vkidDomain: string; + + __localhost?: boolean; + __debug?: boolean; +} diff --git a/src/core/dataService/dataService.ts b/src/core/dataService/dataService.ts new file mode 100644 index 0000000..1dab2e4 --- /dev/null +++ b/src/core/dataService/dataService.ts @@ -0,0 +1,35 @@ +export class DataService { + private readonly promise: Promise; + private callback?: VoidFunction | null; + private resolve: (value: Res) => void; + private reject: (value: Rej) => void; + + public constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + public readonly setCallback = (callback: VoidFunction): void => { + this.callback = callback; + } + + public readonly removeCallback = (): void => { + this.callback = null; + } + + public readonly sendSuccess = (value: Res): void => { + this.resolve(value); + this.callback && this.callback(); + } + + public readonly sendError = (value: Rej): void => { + this.reject(value); + this.callback && this.callback(); + } + + public get value() { + return this.promise; + } +} diff --git a/src/core/dataService/index.ts b/src/core/dataService/index.ts new file mode 100644 index 0000000..907136a --- /dev/null +++ b/src/core/dataService/index.ts @@ -0,0 +1 @@ +export { DataService } from './dataService'; diff --git a/src/core/dataService/types.ts b/src/core/dataService/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/core/dispatcher/dispatcher.ts b/src/core/dispatcher/dispatcher.ts new file mode 100644 index 0000000..72853f5 --- /dev/null +++ b/src/core/dispatcher/dispatcher.ts @@ -0,0 +1,15 @@ +import mitt, { Emitter } from 'mitt'; + +export class Dispatcher { + protected readonly events: Emitter = mitt(); + + public on(event: string, handler: any): this { + this.events.on(event, handler); + return this; + } + + public off(event: string, handler: any): this { + this.events.off(event, handler); + return this; + } +} diff --git a/src/core/dispatcher/index.ts b/src/core/dispatcher/index.ts new file mode 100644 index 0000000..4a493b0 --- /dev/null +++ b/src/core/dispatcher/index.ts @@ -0,0 +1 @@ +export { Dispatcher } from './dispatcher'; diff --git a/src/core/validator/index.ts b/src/core/validator/index.ts new file mode 100644 index 0000000..5e466dd --- /dev/null +++ b/src/core/validator/index.ts @@ -0,0 +1,2 @@ +export { validator } from './validator'; +export { isRequired, isNumber, isValidAppId, isValidHeight } from './rules'; diff --git a/src/core/validator/rules.ts b/src/core/validator/rules.ts new file mode 100644 index 0000000..b4a8cc2 --- /dev/null +++ b/src/core/validator/rules.ts @@ -0,0 +1,41 @@ +import { ValidatorRule } from './types'; + +export const isRequired: ValidatorRule = (param: any) => { + let result = true; + + if (typeof param === 'string' && param.trim() === '' || param === undefined || param == null) { + result = false; + } + + return { + result, + makeError: (valueName: string) => `${valueName} is required parameter`, + }; +}; +export const isNumber: ValidatorRule = (param: any) => { + return { + result: + ['number', 'string'].includes(typeof param) && !isNaN(parseInt(param)), + makeError: (valueName: string) => `${valueName} should be number`, + }; +}; +export const isValidAppId: ValidatorRule = (param: any) => { + return { + result: param === undefined || isNumber(param).result && param > 0, + makeError: (valueName: string) => `${valueName} is not a valid app id`, + }; +}; +export const isValidHeight: ValidatorRule = (param: any) => { + let result = + param !== undefined && + param.height !== undefined && + isNumber(param.height) && + param.height < 57 && + param.height > 31 + || (param === undefined || param.height === undefined); + + return { + result, + makeError: () => 'The height should correspond to the range from 32 to 56', + }; +}; diff --git a/src/core/validator/types.ts b/src/core/validator/types.ts new file mode 100644 index 0000000..0b00231 --- /dev/null +++ b/src/core/validator/types.ts @@ -0,0 +1,4 @@ +export type ValidatorRule = (...args: any[]) => { + result: boolean; + makeError: (name: string) => string; +}; diff --git a/src/core/validator/validator.ts b/src/core/validator/validator.ts new file mode 100644 index 0000000..df26dd3 --- /dev/null +++ b/src/core/validator/validator.ts @@ -0,0 +1,26 @@ +import { ValidatorRule } from './types'; + +export const validator = >(rules: { + [key in keyof T]?: ValidatorRule[]; +}): any => { + return ( + target: any, + propertyName: string, + descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, + ) => { + const originalMethod = descriptor.value; + descriptor.value = function(params: any) { + const rulesKeys = Object.keys(rules); + for (let key of rulesKeys) { + const validateHandlers = rules[key]; + validateHandlers?.forEach((handler) => { + const { result, makeError } = handler(params[key]); + if (!result) { + throw new Error(makeError(key)); + } + }); + } + return originalMethod?.apply(this, arguments); + }; + }; +}; diff --git a/src/core/widget/constants.ts b/src/core/widget/constants.ts new file mode 100644 index 0000000..ce123c7 --- /dev/null +++ b/src/core/widget/constants.ts @@ -0,0 +1,6 @@ +import { WidgetErrorCode, WidgetErrorText } from './types'; + +export const WIDGET_ERROR_TEXT: WidgetErrorText = { + [WidgetErrorCode.TimeoutExceeded]: 'timeout', + [WidgetErrorCode.InternalError]: 'internal error', +}; diff --git a/src/core/widget/events.ts b/src/core/widget/events.ts new file mode 100644 index 0000000..8540c61 --- /dev/null +++ b/src/core/widget/events.ts @@ -0,0 +1,8 @@ +export enum WidgetEvents { + START_LOAD = 'common: start load', + LOAD = 'common: load', + SHOW = 'common: show', + HIDE = 'common: hide', + CLOSE = 'common: close', + ERROR = 'common: error', +} diff --git a/src/core/widget/index.ts b/src/core/widget/index.ts new file mode 100644 index 0000000..6621968 --- /dev/null +++ b/src/core/widget/index.ts @@ -0,0 +1,3 @@ +export { Widget } from './widget'; +export { WidgetEvents } from './events'; +export type { WidgetParams } from './types'; diff --git a/src/core/widget/template.ts b/src/core/widget/template.ts new file mode 100644 index 0000000..41eff52 --- /dev/null +++ b/src/core/widget/template.ts @@ -0,0 +1,30 @@ +export const getWidgetTemplate = (id: string) => { + return ` +
+ +
+
+ +
+ `; +}; diff --git a/src/core/widget/types.ts b/src/core/widget/types.ts new file mode 100644 index 0000000..1cf34da --- /dev/null +++ b/src/core/widget/types.ts @@ -0,0 +1,36 @@ +export type WidgetState = 'loading' | 'loaded' | 'not_loaded'; + +export interface WidgetElements { + root: HTMLElement; + iframe: HTMLIFrameElement; +} + +export interface WidgetParams { + /** + * HTML элемент, в который будет вставлено окно с кнопкой + */ + container: HTMLElement; + /** + * Цветовая схема виджета + */ + scheme?: 'light' | 'dark'; +} + +export enum WidgetErrorCode { + /** + * Не загрузился iframe + */ + TimeoutExceeded = 0, + /** + * Внутренняя ошибка + */ + InternalError = 1 +} + +export type WidgetErrorText = Record; + +export interface WidgetError { + code: WidgetErrorCode; + text: string; + details?: Record; +} diff --git a/src/core/widget/widget.ts b/src/core/widget/widget.ts new file mode 100644 index 0000000..2b8263a --- /dev/null +++ b/src/core/widget/widget.ts @@ -0,0 +1,178 @@ +import { customAlphabet } from 'nanoid/non-secure'; + +import { Auth } from '#/auth'; +import { Bridge, BridgeEvents, BridgeMessage } from '#/core/bridge'; +import { Config, ConfigData } from '#/core/config'; +import { Dispatcher } from '#/core/dispatcher'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { isRequired, validator } from '#/core/validator'; +import { generateCodeChallenge } from '#/utils/oauth'; +import { getVKIDUrl } from '#/utils/url'; + +import { WIDGET_ERROR_TEXT } from './constants'; +import { WidgetEvents } from './events'; +import { getWidgetTemplate } from './template'; +import { WidgetElements, WidgetError, WidgetErrorCode, WidgetParams, WidgetState } from './types'; + +const MODULE_LOAD_TIMEOUT = 5000; +const MODULE_CHANGE_STATE_TIMEOUT = 300; +const CODE_CHALLENGE_METHOD = 's256'; + +export class Widget

> extends Dispatcher { + public static __config: Config; + public static __auth: Auth; + + protected readonly id: string = customAlphabet('qazwsxedcrfvtgbyhnujmikol', 6)(); + + protected vkidAppName = ''; + protected config: Config; + protected timeoutTimer: any; + protected bridge: Bridge; + protected container: HTMLElement; + protected templateRenderer = getWidgetTemplate; + + protected elements: WidgetElements; + + public constructor() { + super(); + this.config = Widget.__config; + } + + @validator({ container: [isRequired] }) + public render(params: WidgetParams & P): this { + this.container = params.container; + this.renderTemplate(); + this.registerElements(); + this.loadWidgetFrame(params); + + return this; + } + + public close() { + clearTimeout(this.timeoutTimer); + this.elements?.root?.remove(); + this.bridge?.destroy(); + this.events.emit(WidgetEvents.CLOSE); + } + + public show(): this { + if (this.elements.root) { + this.elements.root.style.display = 'block'; + this.events.emit(WidgetEvents.SHOW); + } + + return this; + } + + public hide(): this { + if (this.elements.root) { + this.elements.root.style.display = 'none'; + this.events.emit(WidgetEvents.HIDE); + } + + return this; + } + + /** + * Метод вызывается перед началом загрузки iframe с VK ID приложением + */ + protected onStartLoadHandler() { + this.setState('loading'); + this.timeoutTimer = setTimeout(() => { + this.onErrorHandler({ + code: WidgetErrorCode.TimeoutExceeded, + text: WIDGET_ERROR_TEXT[WidgetErrorCode.TimeoutExceeded], + }); + }, MODULE_LOAD_TIMEOUT); + this.events.emit(WidgetEvents.START_LOAD); + } + + /** + * Метод вызывается после того, как полностью загружен iframe с VK ID приложением + */ + protected onLoadHandler() { + clearTimeout(this.timeoutTimer); + setTimeout(() => { + // Задержка избавляет от моргания замены шаблона на iframe + this.setState('loaded'); + }, MODULE_CHANGE_STATE_TIMEOUT); + this.events.emit(WidgetEvents.LOAD); + } + + /** + * Метод вызывается, когда во время работы/загрузки VK ID приложения произошла ошибка + */ + protected onErrorHandler(error: WidgetError) { + this.setState('not_loaded'); + this.events.emit(WidgetEvents.ERROR, error); + this.elements?.iframe?.remove(); + } + + /** + * Метод вызывается при сообщениях от VK ID приложения + */ + protected onBridgeMessageHandler(event: BridgeMessage) { + switch (event.handler) { + case WidgetEvents.LOAD: { + this.onLoadHandler(); + break; + } + case WidgetEvents.CLOSE: { + this.close(); + break; + } + case WidgetEvents.ERROR: { + this.onErrorHandler({ + code: WidgetErrorCode.InternalError, + text: WIDGET_ERROR_TEXT[WidgetErrorCode.InternalError], + details: event.params, + }); + break; + } + default: + break; + } + } + + // <Дополнительные хелперы> + protected renderTemplate() { + this.container.insertAdjacentHTML('beforeend', this.templateRenderer(this.id)); + } + + protected loadWidgetFrame(params: WidgetParams) { + this.onStartLoadHandler(); + this.bridge = new Bridge({ + iframe: this.elements.iframe, + origin: `https://${this.config.get().vkidDomain}`, + }); + this.bridge.on(BridgeEvents.MESSAGE, (event: BridgeMessage) => this.onBridgeMessageHandler(event)); + + this.elements.iframe.src = this.getWidgetFrameSrc(this.config.get(), params); + } + + protected getWidgetFrameSrc(config: ConfigData, params: WidgetParams): string { + const { container, ...otherParams } = params; + const queryParams = { + ...otherParams, + code_challenge: generateCodeChallenge(), + code_challenge_method: CODE_CHALLENGE_METHOD, + origin: location.protocol + '//' + location.host, + uuid: this.id, + }; + + return getVKIDUrl(this.vkidAppName, queryParams, config); + } + + protected setState(state: WidgetState) { + this.elements.root.setAttribute('data-state', state); + } + + protected registerElements() { + const root = document.getElementById(this.id) as HTMLElement; + + this.elements = { + root, + iframe: root.querySelector('iframe') as HTMLIFrameElement, + }; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dc68af5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +import { Auth, AuthParams, AuthError, AuthResponse, AuthErrorCode } from './auth'; +import { Config, ConfigData } from './core/config'; +import { Widget } from './core/widget'; +import { Languages } from './types'; + +export type { Languages }; + +const globalConfig = new Config(); +export { globalConfig as Config }; +export type { ConfigData }; + +Auth.__config = globalConfig; +const globalAuth = new Auth(); +export { globalAuth as Auth }; +export type { AuthParams, AuthError, AuthResponse, AuthErrorCode }; + +Widget.__config = globalConfig; +Widget.__auth = globalAuth; + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..24396ec --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +export enum Languages { + RUS = '0', + UKR = '1', + ENG = '3', + SPA = '4', + GERMAN = '6', + POL = '15', + FRA = '16', + TURKEY = '82', +} diff --git a/src/utils/domain.ts b/src/utils/domain.ts new file mode 100644 index 0000000..05ec4a6 --- /dev/null +++ b/src/utils/domain.ts @@ -0,0 +1,6 @@ +const ALLOWED_DOMAINS = [ + 'vk.com', + 'vk.ru', +]; + +export const isDomainAllowed = (origin: string): boolean => !!ALLOWED_DOMAINS.find((domain) => origin.endsWith(domain)); diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts new file mode 100644 index 0000000..d6bf645 --- /dev/null +++ b/src/utils/oauth.ts @@ -0,0 +1,17 @@ +import Base64 from 'crypto-js/enc-base64'; +import sha256 from 'crypto-js/sha256'; +import { nanoid } from 'nanoid/non-secure'; + +/** + Генерация code challenge для нового oauth + */ +export const generateCodeChallenge = (): string => { + const codeVerifier = nanoid(); + const hash = sha256(codeVerifier); + const base64 = Base64.stringify(hash); + + return base64 + .replace(/=*$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +}; diff --git a/src/utils/styles.ts b/src/utils/styles.ts new file mode 100644 index 0000000..be8c92c --- /dev/null +++ b/src/utils/styles.ts @@ -0,0 +1,27 @@ +export const getButtonPadding = (height: number) => { + const res = (height - 30) / 2 + 3; + if (height < 40) { + return res; + } + return res - 2; +}; + +export const getButtonFontSize = (height: number) => { + if (height < 40) { + return 14; + } + + if (height > 47) { + return 17; + } + + return 16; +}; + +export const getButtonLogoSize = (height: number) => { + if (height < 40) { + return 24; + } + + return 28; +}; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..46d14a6 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,20 @@ +import { querystring } from '@vkontakte/vkjs'; + +import { VERSION } from '#/constants'; +import { ConfigData } from '#/core/config'; + +export const getVKIDUrl = (module: string, params: Record, config: ConfigData): string => { + const queryParams: Record = { + ...params, + v: VERSION, + app_id: config.app, + sdk_type: 'vkid', + + debug: config.__debug ? 1 : null, + localhost: config.__localhost ? 1 : null, + }; + + const queryParamsString = querystring.stringify(queryParams, { skipNull: true }); + + return `https://${config.vkidDomain}/${module}?${queryParamsString}`; +}; diff --git a/src/widgets/agreementsDialog/agreementsDialog.ts b/src/widgets/agreementsDialog/agreementsDialog.ts new file mode 100644 index 0000000..91b11d7 --- /dev/null +++ b/src/widgets/agreementsDialog/agreementsDialog.ts @@ -0,0 +1,26 @@ +import { BridgeMessage } from '#/core/bridge'; +import { Widget, WidgetEvents } from '#/core/widget'; + +import { AgreementsDialogInternalEvents } from './events'; +import { getAgreementsDialogTemplate } from './template'; + +export class AgreementsDialog extends Widget { + protected vkidAppName = 'user_policy_agreements'; + protected templateRenderer = getAgreementsDialogTemplate; + + protected onBridgeMessageHandler(event: BridgeMessage) { + switch (event.handler) { + case AgreementsDialogInternalEvents.DECLINE: { + this.close(); + break; + } + case AgreementsDialogInternalEvents.ACCEPT: { + this.events.emit(AgreementsDialogInternalEvents.ACCEPT, event); + break; + } + default: + super.onBridgeMessageHandler(event); + break; + } + } +} diff --git a/src/widgets/agreementsDialog/events.ts b/src/widgets/agreementsDialog/events.ts new file mode 100644 index 0000000..5016a8d --- /dev/null +++ b/src/widgets/agreementsDialog/events.ts @@ -0,0 +1,5 @@ +export enum AgreementsDialogInternalEvents { + ACCEPT = 'agreements dialog: accept', + DECLINE = 'agreements dialog: decline', +} + diff --git a/src/widgets/agreementsDialog/index.ts b/src/widgets/agreementsDialog/index.ts new file mode 100644 index 0000000..7bab451 --- /dev/null +++ b/src/widgets/agreementsDialog/index.ts @@ -0,0 +1 @@ +export { AgreementsDialog } from './agreementsDialog'; diff --git a/src/widgets/agreementsDialog/template.ts b/src/widgets/agreementsDialog/template.ts new file mode 100644 index 0000000..0bce2c2 --- /dev/null +++ b/src/widgets/agreementsDialog/template.ts @@ -0,0 +1,22 @@ +export const getAgreementsDialogTemplate = (id: string) => { + return ` +

+ +