diff --git a/package.json b/package.json index 29f0e79bf..51b9c6cb4 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,8 @@ "engines": { "node": ">=14.15.0", "pnpm": ">=6" + }, + "alias": { + "html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js" } } diff --git a/packages/console/package.json b/packages/console/package.json index 3ca2df9c9..e175f2629 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -15,6 +15,8 @@ "stylelint": "stylelint \"src/**/*.scss\"" }, "dependencies": { + "@logto/phrases": "^0.1.0", + "@logto/schemas": "^0.1.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, diff --git a/packages/phrases/package.json b/packages/phrases/package.json index e7a3111a4..e37e5cd10 100644 --- a/packages/phrases/package.json +++ b/packages/phrases/package.json @@ -25,6 +25,7 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { + "@logto/schemas": "^0.1.0", "@silverhand/essentials": "^1.1.4" }, "devDependencies": { diff --git a/packages/ui-new/package.json b/packages/ui-new/package.json index db993c54c..1ad5eb67f 100644 --- a/packages/ui-new/package.json +++ b/packages/ui-new/package.json @@ -8,13 +8,20 @@ "precommit": "lint-staged", "start": "parcel src/index.html", "check": "tsc --noEmit", - "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall", - "lint": "eslint --ext .ts --ext .tsx src", + "build": "rm -rf dist && parcel build src/index.html --no-autoinstall", "stylelint": "stylelint \"src/**/*.scss\"" }, "dependencies": { + "@logto/phrases": "^0.1.0", + "@logto/schemas": "^0.1.0", + "classnames": "^2.3.1", + "i18next": "^21.6.11", + "i18next-browser-languagedetector": "^6.1.3", + "ky": "^0.29.0", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-i18next": "^11.15.4", + "react-router-dom": "^5.2.0" }, "devDependencies": { "@parcel/core": "^2.3.1", @@ -25,6 +32,7 @@ "@silverhand/ts-config-react": "^0.8.1", "@types/react": "^17.0.14", "@types/react-dom": "^17.0.9", + "@types/react-router-dom": "^5.3.2", "eslint": "^8.1.0", "lint-staged": "^11.1.1", "parcel": "^2.3.1", @@ -33,6 +41,9 @@ "prettier": "^2.3.2", "stylelint": "^13.13.1" }, + "alias": { + "@/*": "./src/$1" + }, "eslintConfig": { "extends": "@silverhand/react" }, diff --git a/packages/ui-new/src/App.module.scss b/packages/ui-new/src/App.module.scss deleted file mode 100644 index 79f828507..000000000 --- a/packages/ui-new/src/App.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.app { - color: #aaa; -} diff --git a/packages/ui-new/src/App.tsx b/packages/ui-new/src/App.tsx index e60dbd8bd..1bb2134d9 100644 --- a/packages/ui-new/src/App.tsx +++ b/packages/ui-new/src/App.tsx @@ -1,7 +1,30 @@ import React from 'react'; +import { Route, Switch, BrowserRouter } from 'react-router-dom'; -import * as styles from './App.module.scss'; +import AppContent from './components/AppContent'; +import useTheme from './hooks/use-theme'; +import initI18n from './i18n/init'; +import Consent from './pages/Consent'; +import Register from './pages/Register'; +import SignIn from './pages/SignIn'; +import './scss/normalized.scss'; -export const App = () => { - return

Hello world!

; +void initI18n(); + +const App = () => { + const theme = useTheme(); + + return ( + + + + + + + + + + ); }; + +export default App; diff --git a/packages/ui-new/src/__mocks__/react-i18next.js b/packages/ui-new/src/__mocks__/react-i18next.js new file mode 100644 index 000000000..ec7def20b --- /dev/null +++ b/packages/ui-new/src/__mocks__/react-i18next.js @@ -0,0 +1,59 @@ +// Copied from https://github.com/i18next/react-i18next/blob/c533ffe092470b58769586681abfc2cf4ecf0b56/example/test-jest/src/__mocks__/react-i18next.js +/* eslint-disable unicorn/prefer-module */ +const React = require('react'); +const reactI18next = require('react-i18next'); + +const hasChildren = (node) => node && (node.children || (node.props && node.props.children)); + +const getChildren = (node) => + node && node.children ? node.children : node.props && node.props.children; + +const renderNodes = (reactNodes) => { + if (typeof reactNodes === 'string') { + return reactNodes; + } + + return Object.keys(reactNodes).map((key, i) => { + const child = reactNodes[key]; + const isElement = React.isValidElement(child); + + if (typeof child === 'string') { + return child; + } + + if (hasChildren(child)) { + const inner = renderNodes(getChildren(child)); + // eslint-disable-next-line react/no-array-index-key + return React.cloneElement(child, { ...child.props, key: i }, inner); + } + + if (typeof child === 'object' && !isElement) { + return Object.keys(child).reduce((string, childKey) => `${string}${child[childKey]}`, ''); + } + + return child; + }); +}; + +const useMock = [(k) => k, {}]; +useMock.t = (k) => k; +useMock.i18n = {}; + +const withTranslation = (Component) => (props) => k} {...props} />; + +module.exports = { + // This mock makes sure any components using the translate HoC receive the t function as a prop + withTranslation: () => withTranslation, + Trans: ({ children }) => + Array.isArray(children) ? renderNodes(children) : renderNodes([children]), + Translation: ({ children }) => children((k) => k, { i18n: {} }), + useTranslation: () => useMock, + + // Mock if needed + I18nextProvider: reactI18next.I18nextProvider, + initReactI18next: reactI18next.initReactI18next, + setDefaults: reactI18next.setDefaults, + getDefaults: reactI18next.getDefaults, + setI18n: reactI18next.setI18n, + getI18n: reactI18next.getI18n, +}; diff --git a/packages/ui-new/src/apis/consent.ts b/packages/ui-new/src/apis/consent.ts new file mode 100644 index 000000000..18749e32a --- /dev/null +++ b/packages/ui-new/src/apis/consent.ts @@ -0,0 +1,9 @@ +import ky from 'ky'; + +export const consent = async () => { + type Response = { + redirectTo: string; + }; + + return ky.post('/api/session/consent').json(); +}; diff --git a/packages/ui-new/src/apis/register.ts b/packages/ui-new/src/apis/register.ts new file mode 100644 index 000000000..6f9a40e04 --- /dev/null +++ b/packages/ui-new/src/apis/register.ts @@ -0,0 +1,16 @@ +import ky from 'ky'; + +export const register = async (username: string, password: string) => { + type Response = { + redirectTo: string; + }; + + return ky + .post('/api/session/register', { + json: { + username, + password, + }, + }) + .json(); +}; diff --git a/packages/ui-new/src/apis/sign-in.ts b/packages/ui-new/src/apis/sign-in.ts new file mode 100644 index 000000000..6783604c3 --- /dev/null +++ b/packages/ui-new/src/apis/sign-in.ts @@ -0,0 +1,16 @@ +import ky from 'ky'; + +export const signInBasic = async (username: string, password: string) => { + type Response = { + redirectTo: string; + }; + + return ky + .post('/api/session', { + json: { + username, + password, + }, + }) + .json(); +}; diff --git a/packages/ui-new/src/components/AppContent/index.module.scss b/packages/ui-new/src/components/AppContent/index.module.scss new file mode 100644 index 000000000..501ad99fd --- /dev/null +++ b/packages/ui-new/src/components/AppContent/index.module.scss @@ -0,0 +1,71 @@ +.content { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background: var(--color-background); + color: var(--color-body); +} + +.universal { + /* Color */ + --color-gradient: linear-gradient(12.07deg, #3c4ce3 8.81%, #717ce0 93.49%); + --color-button-background: #3c4ce3; + --color-button-background-disabled: #626fe8; + --color-button-background-hover: #2234df; + --color-button-text: #f5f5f5; + --color-button-text-disabled: #eee; + --color-error: #ff6b66; + --color-error-background: #{rgba(#ff6b66, 0.15)}; + --color-error-border: #{rgba(#ff6b66, 0.35)}; + + /* Shadow */ + --shadow-button: 2px 2px 8px rgb(60 76 227 / 25%); + + /* Transition */ + --transition-default-function: 0.2s ease-in-out; + --transition-default-control: background var(--transition-default-function), color var(--transition-default-function); +} + +.dark { + /* Color */ + --color-heading: #f3f6f9; + --color-body: #dadae0; + --color-secondary: #b3b7ba; + --color-placeholder: #888; + --color-control-background: #45484a; + --color-control-background-disabled: #65686a; + --color-background: #101419; + + /* Shadow */ + --shadow-card: 2px 2px 24px rgb(59 61 63 / 25%); + --shadow-control: 1px 1px 2px rgb(221 221 221 / 25%); +} + +.light { + /* Color */ + --color-heading: #333; + --color-body: #555; + --color-secondary: #888; + --color-placeholder: #aaa; + --color-control-background: #f5f8fa; + --color-control-background-disabled: #eaeaea; + --color-background: #fdfdff; + + /* Shadow */ + --shadow-card: 2px 2px 24px rgb(187 189 191 / 20%); + --shadow-control: 1px 1px 2px rgb(221 221 221 / 25%); +} + +$font-family: 'PingFang SC', 'SF Pro Text', sans-serif; + +.mobile { + --font-headline: 600 40px/56px #{$font-family}; + --font-heading-1: 600 28px/39px #{$font-family}; + --font-heading-2: 600 20px/28px #{$font-family}; + --font-heading-3: 600 16px/22.4px #{$font-family}; + --font-control: 500 16px/22.4px #{$font-family}; + --font-body: 400 12px/16px #{$font-family}; + --font-body-bold: 500 12px/16px #{$font-family}; +} diff --git a/packages/ui-new/src/components/AppContent/index.tsx b/packages/ui-new/src/components/AppContent/index.tsx new file mode 100644 index 000000000..6640a07ff --- /dev/null +++ b/packages/ui-new/src/components/AppContent/index.tsx @@ -0,0 +1,21 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; + +import * as styles from './index.module.scss'; + +export type Theme = 'dark' | 'light'; + +export type Props = { + theme: Theme; + children: ReactNode; +}; + +const AppContent = ({ children, theme }: Props) => { + return ( +
+ {children} +
+ ); +}; + +export default AppContent; diff --git a/packages/ui-new/src/components/Button/index.module.scss b/packages/ui-new/src/components/Button/index.module.scss new file mode 100644 index 000000000..028eb2aa4 --- /dev/null +++ b/packages/ui-new/src/components/Button/index.module.scss @@ -0,0 +1,26 @@ +@use '@/scss/underscore' as _; + +.button { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: _.unit(3) _.unit(8); + border-radius: 12px; + background: var(--color-button-background); + color: var(--color-button-text); + font: var(--font-control); + box-shadow: var(--shadow-button); + transition: var(--transition-default-control); + cursor: pointer; + -webkit-appearance: none; + + &:disabled { + background: var(--color-button-background-disabled); + color: var(--color-button-text-disabled); + } + + &:not(:disabled):hover { + background: var(--color-button-background-hover); + } +} diff --git a/packages/ui-new/src/components/Button/index.tsx b/packages/ui-new/src/components/Button/index.tsx new file mode 100644 index 000000000..ba6ceb0d7 --- /dev/null +++ b/packages/ui-new/src/components/Button/index.tsx @@ -0,0 +1,25 @@ +import classNames from 'classnames'; +import React from 'react'; + +import * as styles from './index.module.scss'; + +export type Props = { + isDisabled?: boolean; + className?: string; + value?: string; + onClick?: React.MouseEventHandler; +}; + +const Button = ({ isDisabled, className, value, onClick }: Props) => { + return ( + + ); +}; + +export default Button; diff --git a/packages/ui-new/src/components/Input/index.module.scss b/packages/ui-new/src/components/Input/index.module.scss new file mode 100644 index 000000000..a3c03f668 --- /dev/null +++ b/packages/ui-new/src/components/Input/index.module.scss @@ -0,0 +1,23 @@ +@use '@/scss/underscore' as _; + +.input { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: _.unit(3) _.unit(5); + border-radius: _.unit(2); + background: var(--color-control-background); + color: var(--color-heading); + font: var(--font-control); + transition: var(--transition-default-control); + + &::placeholder { + color: var(--color-placeholder); + } + + &:disabled { + background: var(--color-control-background-disabled); + color: var(--color-secondary); + } +} diff --git a/packages/ui-new/src/components/Input/index.tsx b/packages/ui-new/src/components/Input/index.tsx new file mode 100644 index 000000000..bc07cbfc0 --- /dev/null +++ b/packages/ui-new/src/components/Input/index.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import React from 'react'; + +import * as styles from './index.module.scss'; + +export type Props = { + name: string; + autoComplete?: AutoCompleteType; + isDisabled?: boolean; + className?: string; + placeholder?: string; + type?: InputType; + value: string; + onChange: (value: string) => void; +}; + +const Input = ({ + name, + autoComplete, + isDisabled, + className, + placeholder, + type = 'text', + value, + onChange, +}: Props) => { + return ( + { + onChange(value); + }} + /> + ); +}; + +export default Input; diff --git a/packages/ui-new/src/components/MessageBox/index.module.scss b/packages/ui-new/src/components/MessageBox/index.module.scss new file mode 100644 index 000000000..c6586256b --- /dev/null +++ b/packages/ui-new/src/components/MessageBox/index.module.scss @@ -0,0 +1,13 @@ +@use '@/scss/underscore' as _; + +.messageBox { + font: var(--font-body-bold); + padding: _.unit(2) _.unit(5); + border-radius: _.unit(); + + &.error { + color: var(--color-error); + background: var(--color-error-background); + border: 1px solid var(--color-error-border); + } +} diff --git a/packages/ui-new/src/components/MessageBox/index.tsx b/packages/ui-new/src/components/MessageBox/index.tsx new file mode 100644 index 000000000..698a96ea6 --- /dev/null +++ b/packages/ui-new/src/components/MessageBox/index.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; + +import * as styles from './index.module.scss'; + +export type Props = { + className?: string; + children: ReactNode; +}; + +const MessageBox = ({ className, children }: Props) => { + return
{children}
; +}; + +export default MessageBox; diff --git a/packages/ui-new/src/components/TextLink/index.module.scss b/packages/ui-new/src/components/TextLink/index.module.scss new file mode 100644 index 000000000..0795288b6 --- /dev/null +++ b/packages/ui-new/src/components/TextLink/index.module.scss @@ -0,0 +1,12 @@ +@use '@/scss/underscore' as _; + +.link { + color: var(--color-button-background); + font: var(--font-body-bold); + transition: var(--transition-default-control); + cursor: pointer; + + &:hover { + color: var(--color-button-background-hover); + } +} diff --git a/packages/ui-new/src/components/TextLink/index.tsx b/packages/ui-new/src/components/TextLink/index.tsx new file mode 100644 index 000000000..9808dac18 --- /dev/null +++ b/packages/ui-new/src/components/TextLink/index.tsx @@ -0,0 +1,20 @@ +import classNames from 'classnames'; +import React, { ReactChild } from 'react'; + +import * as styles from './index.module.scss'; + +export type Props = { + className?: string; + children: ReactChild; + href: string; +}; + +const TextLink = ({ className, children, href }: Props) => { + return ( + + {children} + + ); +}; + +export default TextLink; diff --git a/packages/ui-new/src/hooks/use-api.ts b/packages/ui-new/src/hooks/use-api.ts new file mode 100644 index 000000000..c056590e0 --- /dev/null +++ b/packages/ui-new/src/hooks/use-api.ts @@ -0,0 +1,52 @@ +import { RequestErrorBody } from '@logto/schemas'; +import { HTTPError } from 'ky'; +import { useState, useCallback } from 'react'; + +type UseApi = { + result?: U; + loading: boolean; + error: RequestErrorBody | null; + run: (...args: T) => Promise; +}; + +function useApi( + api: (...args: Args) => Promise +): UseApi { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(); + + const run = useCallback( + async (...args: Args) => { + setLoading(true); + setError(null); + + try { + const result = await api(...args); + setResult(result); + setLoading(false); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const kyError = await error.response.json(); + setError(kyError); + setLoading(false); + + return; + } + + setLoading(false); + throw error; + } + }, + [api] + ); + + return { + loading, + error, + result, + run, + }; +} + +export default useApi; diff --git a/packages/ui-new/src/hooks/use-theme.ts b/packages/ui-new/src/hooks/use-theme.ts new file mode 100644 index 000000000..d7c9f2f38 --- /dev/null +++ b/packages/ui-new/src/hooks/use-theme.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +import { Theme } from '@/components/AppContent'; + +const darkThemeWatchMedia = window.matchMedia('(prefers-color-scheme: dark)'); +const getThemeBySystemConfiguration = (): Theme => (darkThemeWatchMedia.matches ? 'dark' : 'light'); + +export default function useTheme() { + const [theme, setTheme] = useState(getThemeBySystemConfiguration()); + + useEffect(() => { + const changeTheme = () => { + setTheme(getThemeBySystemConfiguration()); + }; + + darkThemeWatchMedia.addEventListener('change', changeTheme); + + return () => { + darkThemeWatchMedia.removeEventListener('change', changeTheme); + }; + }, []); + + return theme; +} diff --git a/packages/ui-new/src/i18n/init.ts b/packages/ui-new/src/i18n/init.ts new file mode 100644 index 000000000..142bc4129 --- /dev/null +++ b/packages/ui-new/src/i18n/init.ts @@ -0,0 +1,18 @@ +import resources from '@logto/phrases'; +import i18next from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +const initI18n = async () => + i18next + .use(initReactI18next) + .use(LanguageDetector) + .init({ + resources, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); + +export default initI18n; diff --git a/packages/ui-new/src/include.d/dom.d.ts b/packages/ui-new/src/include.d/dom.d.ts new file mode 100644 index 000000000..d6c0c1615 --- /dev/null +++ b/packages/ui-new/src/include.d/dom.d.ts @@ -0,0 +1,76 @@ +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types +type InputType = + | 'button' + | 'checkbox' + | 'color' + | 'date' + | 'datetime-local' + | 'email' + | 'file' + | 'hidden' + | 'image' + | 'month' + | 'number' + | 'password' + | 'radio' + | 'range' + | 'reset' + | 'search' + | 'submit' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week'; + +// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute +type AutoCompleteType = + | 'name' + | 'honorific-prefix' + | 'given-name' + | 'additional-name' + | 'family-name' + | 'honorific-suffix' + | 'nickname' + | 'username' + | 'new-password' + | 'current-password' + | 'one-time-code' + | 'organization-title' + | 'organization' + | 'street-address' + | 'address-line1' + | 'address-line2' + | 'address-line3' + | 'address-level4' + | 'address-level3' + | 'address-level2' + | 'address-level1' + | 'country' + | 'country-name' + | 'postal-code' + | 'cc-name' + | 'cc-given-name' + | 'cc-additional-name' + | 'cc-family-name' + | 'cc-number' + | 'cc-exp' + | 'cc-exp-month' + | 'cc-exp-year' + | 'cc-csc' + | 'cc-type' + | 'transaction-currency' + | 'transaction-amount' + | 'language' + | 'bday' + | 'bday-day' + | 'bday-month' + | 'bday-year' + | 'sex' + | 'url' + | 'photo'; + +// TO-DO: remove me +interface Body { + json(): Promise; +} diff --git a/packages/ui-new/src/include.d/react-i18next.d.ts b/packages/ui-new/src/include.d/react-i18next.d.ts new file mode 100644 index 000000000..adbe43039 --- /dev/null +++ b/packages/ui-new/src/include.d/react-i18next.d.ts @@ -0,0 +1,11 @@ +// https://react.i18next.com/latest/typescript#create-a-declaration-file + +// eslint-disable-next-line import/no-unassigned-import +import 'react-i18next'; +import en from '@logto/phrases/lib/locales/en.js'; + +declare module 'react-i18next' { + interface CustomTypeOptions { + resources: typeof en; + } +} diff --git a/packages/ui-new/src/index.tsx b/packages/ui-new/src/index.tsx index c4b18d95f..09a8551e3 100644 --- a/packages/ui-new/src/index.tsx +++ b/packages/ui-new/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { App } from './App'; +import App from './App'; const app = document.querySelector('#app'); ReactDOM.render(, app); diff --git a/packages/ui-new/src/pages/Consent/index.tsx b/packages/ui-new/src/pages/Consent/index.tsx new file mode 100644 index 000000000..a20c55ab1 --- /dev/null +++ b/packages/ui-new/src/pages/Consent/index.tsx @@ -0,0 +1,24 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { consent } from '@/apis/consent'; +import useApi from '@/hooks/use-api'; + +const Consent = () => { + const { t } = useTranslation(); + const { result, run: asyncConsent } = useApi(consent); + + useEffect(() => { + void asyncConsent(); + }, [asyncConsent]); + + useEffect(() => { + if (result?.redirectTo) { + window.location.assign(result.redirectTo); + } + }, [result]); + + return
{t('sign_in.loading')}
; +}; + +export default Consent; diff --git a/packages/ui-new/src/pages/Register/index.module.scss b/packages/ui-new/src/pages/Register/index.module.scss new file mode 100644 index 000000000..047468c80 --- /dev/null +++ b/packages/ui-new/src/pages/Register/index.module.scss @@ -0,0 +1,50 @@ +@use '@/scss/underscore' as _; + + +.wrapper { + position: relative; + padding: _.unit(8); + height: 100%; + @include _.flex-colomn; +} + +.form { + width: 100%; + @include _.flex-colomn; + + > * { + margin-bottom: _.unit(1.5); + } + + .title { + font: var(--font-heading-1); + color: var(--color-heading); + margin-bottom: _.unit(9); + } + + .box { + margin-bottom: _.unit(-6); + } + + .box, + > input:not([type='button']) { + margin-top: _.unit(3); + width: 100%; + max-width: 320px; + } + + > input[type='button'] { + margin-top: _.unit(12); + } + + .haveAccount { + position: absolute; + bottom: _.unit(10); + } + + .prefix { + font: var(--font-body-bold); + color: var(--color-placeholder); + margin-right: _.unit(0.5); + } +} diff --git a/packages/ui-new/src/pages/Register/index.test.tsx b/packages/ui-new/src/pages/Register/index.test.tsx new file mode 100644 index 000000000..cc89cac01 --- /dev/null +++ b/packages/ui-new/src/pages/Register/index.test.tsx @@ -0,0 +1,23 @@ +import { render, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { register } from '@/apis/register'; +import Register from '@/pages/Register'; + +jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) })); + +describe('', () => { + test('renders without exploding', async () => { + const { queryByText, getByText } = render(); + expect(queryByText('register.create_account')).not.toBeNull(); + expect(queryByText('register.have_account')).not.toBeNull(); + + const submit = getByText('register.action'); + fireEvent.click(submit); + + await waitFor(() => { + expect(register).toBeCalled(); + expect(queryByText('register.loading')).not.toBeNull(); + }); + }); +}); diff --git a/packages/ui-new/src/pages/Register/index.tsx b/packages/ui-new/src/pages/Register/index.tsx new file mode 100644 index 000000000..200940a6b --- /dev/null +++ b/packages/ui-new/src/pages/Register/index.tsx @@ -0,0 +1,75 @@ +import { LogtoErrorI18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { register } from '@/apis/register'; +import Button from '@/components/Button'; +import Input from '@/components/Input'; +import MessageBox from '@/components/MessageBox'; +import TextLink from '@/components/TextLink'; +import useApi from '@/hooks/use-api'; + +import * as styles from './index.module.scss'; + +const Register: FC = () => { + const { t, i18n } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const { loading, error, result, run: asyncRegister } = useApi(register); + + const signUp: FormEventHandler = useCallback( + async (event) => { + event.preventDefault(); + await asyncRegister(username, password); + }, + [username, password, asyncRegister] + ); + + useEffect(() => { + if (result?.redirectTo) { + window.location.assign(result.redirectTo); + } + }, [result]); + + return ( +
+
+
{t('register.create_account')}
+ + + {error && ( + + {i18n.t(`errors:${error.code}`)} + + )} +
+ ); +}; + +export default Register; diff --git a/packages/ui-new/src/pages/SignIn/index.module.scss b/packages/ui-new/src/pages/SignIn/index.module.scss new file mode 100644 index 000000000..0626ddb6f --- /dev/null +++ b/packages/ui-new/src/pages/SignIn/index.module.scss @@ -0,0 +1,51 @@ +@use '@/scss/underscore' as _; + + +@mixin flex-colomn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.wrapper { + position: relative; + padding: _.unit(8); + height: 100%; + @include flex-colomn; +} + +.form { + width: 100%; + @include flex-colomn; + + > * { + margin-bottom: _.unit(1.5); + } + + .title { + font: var(--font-heading-1); + color: var(--color-heading); + margin-bottom: _.unit(9); + } + + .box { + margin-bottom: _.unit(-6); + } + + .box, + > input:not([type='button']) { + margin-top: _.unit(3); + width: 100%; + max-width: 320px; + } + + > input[type='button'] { + margin-top: _.unit(12); + } + + .createAccount { + position: absolute; + bottom: _.unit(10); + } +} diff --git a/packages/ui-new/src/pages/SignIn/index.test.tsx b/packages/ui-new/src/pages/SignIn/index.test.tsx new file mode 100644 index 000000000..a92fb4f0a --- /dev/null +++ b/packages/ui-new/src/pages/SignIn/index.test.tsx @@ -0,0 +1,22 @@ +import { render, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { signInBasic } from '@/apis/sign-in'; +import SignIn from '@/pages/SignIn'; + +jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) })); + +describe('', () => { + test('renders without exploding', async () => { + const { queryByText, getByText } = render(); + expect(queryByText('Sign in to Logto')).not.toBeNull(); + + const submit = getByText('sign_in.action'); + fireEvent.click(submit); + + await waitFor(() => { + expect(signInBasic).toBeCalled(); + expect(queryByText('sign_in.loading')).not.toBeNull(); + }); + }); +}); diff --git a/packages/ui-new/src/pages/SignIn/index.tsx b/packages/ui-new/src/pages/SignIn/index.tsx new file mode 100644 index 000000000..d7ef67109 --- /dev/null +++ b/packages/ui-new/src/pages/SignIn/index.tsx @@ -0,0 +1,76 @@ +import { LogtoErrorI18nKey } from '@logto/phrases'; +import classNames from 'classnames'; +import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { signInBasic } from '@/apis/sign-in'; +import Button from '@/components/Button'; +import Input from '@/components/Input'; +import MessageBox from '@/components/MessageBox'; +import TextLink from '@/components/TextLink'; +import useApi from '@/hooks/use-api'; + +import * as styles from './index.module.scss'; + +const SignIn: FC = () => { + // TODO: Consider creating cross page data modal + const { t, i18n } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const { loading, error, result, run: asyncSignInBasic } = useApi(signInBasic); + + const signInHandler: FormEventHandler = useCallback( + async (event) => { + event.preventDefault(); + await asyncSignInBasic(username, password); + }, + [username, password, asyncSignInBasic] + ); + + useEffect(() => { + if (result?.redirectTo) { + window.location.assign(result.redirectTo); + } + }, [result]); + + return ( +
+
+
Sign in to Logto
+ + + {error && ( + + {i18n.t(`errors:${error.code}`)} + + )} +
+ ); +}; + +export default SignIn; diff --git a/packages/ui-new/src/scss/_underscore.scss b/packages/ui-new/src/scss/_underscore.scss new file mode 100644 index 000000000..8f5c5b957 --- /dev/null +++ b/packages/ui-new/src/scss/_underscore.scss @@ -0,0 +1,10 @@ +@function unit($factor: 1, $unit: 'px') { + @return #{$factor * 4}#{$unit}; +} + +@mixin flex-colomn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} diff --git a/packages/ui-new/src/scss/normalized.scss b/packages/ui-new/src/scss/normalized.scss new file mode 100644 index 000000000..1fd2e7d97 --- /dev/null +++ b/packages/ui-new/src/scss/normalized.scss @@ -0,0 +1,14 @@ +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} + +* { + box-sizing: border-box; +} + +input { + border: none; + outline: none; +} diff --git a/packages/ui-new/tsconfig.json b/packages/ui-new/tsconfig.json index f2cc02dc3..d989b11ed 100644 --- a/packages/ui-new/tsconfig.json +++ b/packages/ui-new/tsconfig.json @@ -1,5 +1,12 @@ { "extends": "@silverhand/ts-config-react/tsconfig.base", + "compilerOptions": { + "paths": { + "@/*": [ + "./src/*" + ] + } + }, "include": [ "src" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b752b4447..079042195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,8 @@ importers: packages/console: specifiers: + '@logto/phrases': ^0.1.0 + '@logto/schemas': ^0.1.0 '@parcel/core': ^2.3.1 '@parcel/transformer-sass': ^2.3.1 '@silverhand/eslint-config': ^0.8.1 @@ -39,6 +41,8 @@ importers: stylelint: ^13.13.1 typescript: ^4.5.5 dependencies: + '@logto/phrases': link:../phrases + '@logto/schemas': link:../schemas react: 17.0.2 react-dom: 17.0.2_react@17.0.2 devDependencies: @@ -166,6 +170,7 @@ importers: packages/phrases: specifiers: + '@logto/schemas': ^0.1.0 '@silverhand/eslint-config': ^0.8.1 '@silverhand/essentials': ^1.1.4 '@silverhand/ts-config': ^0.8.1 @@ -174,6 +179,7 @@ importers: prettier: ^2.3.2 typescript: ^4.5.5 dependencies: + '@logto/schemas': link:../schemas '@silverhand/essentials': 1.1.4 devDependencies: '@silverhand/eslint-config': 0.8.1_b07be603d0ceb19daeedad1772e0f2c4 @@ -309,6 +315,8 @@ importers: packages/ui-new: specifiers: + '@logto/phrases': ^0.1.0 + '@logto/schemas': ^0.1.0 '@parcel/core': ^2.3.1 '@parcel/transformer-sass': ^2.3.1 '@silverhand/eslint-config': ^0.8.1 @@ -317,7 +325,12 @@ importers: '@silverhand/ts-config-react': ^0.8.1 '@types/react': ^17.0.14 '@types/react-dom': ^17.0.9 + '@types/react-router-dom': ^5.3.2 + classnames: ^2.3.1 eslint: ^8.1.0 + i18next: ^21.6.11 + i18next-browser-languagedetector: ^6.1.3 + ky: ^0.29.0 lint-staged: ^11.1.1 parcel: ^2.3.1 postcss: ^8.4.6 @@ -325,10 +338,20 @@ importers: prettier: ^2.3.2 react: ^17.0.2 react-dom: ^17.0.2 + react-i18next: ^11.15.4 + react-router-dom: ^5.2.0 stylelint: ^13.13.1 dependencies: + '@logto/phrases': link:../phrases + '@logto/schemas': link:../schemas + classnames: 2.3.1 + i18next: 21.6.11 + i18next-browser-languagedetector: 6.1.3 + ky: 0.29.0 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 + react-i18next: 11.15.4_3fb644aa30122a07f960d67fa51d6dc1 + react-router-dom: 5.3.0_react@17.0.2 devDependencies: '@parcel/core': 2.3.1 '@parcel/transformer-sass': 2.3.1_@parcel+core@2.3.1 @@ -338,6 +361,7 @@ importers: '@silverhand/ts-config-react': 0.8.1_typescript@4.5.5 '@types/react': 17.0.37 '@types/react-dom': 17.0.11 + '@types/react-router-dom': 5.3.2 eslint: 8.4.1 lint-staged: 11.2.6 parcel: 2.3.1_postcss@8.4.6 @@ -9248,12 +9272,24 @@ packages: '@babel/runtime': 7.16.3 dev: false + /i18next-browser-languagedetector/6.1.3: + resolution: {integrity: sha512-T+oGXHXtrur14CGnZZ7qQ07X38XJQEI00b/4ILrtO6xPbwTlQ1wtMZC2H+tBULixHuVUXv8LKbxfjyITJkezUg==} + dependencies: + '@babel/runtime': 7.16.3 + dev: false + /i18next/20.6.1: resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==} dependencies: '@babel/runtime': 7.16.3 dev: false + /i18next/21.6.11: + resolution: {integrity: sha512-tJ2+o0lVO+fhi8bPkCpBAeY1SgkqmQm5NzgPWCQssBrywJw98/o+Kombhty5nxQOpHtvMmsxcOopczUiH6bJxQ==} + dependencies: + '@babel/runtime': 7.16.3 + dev: false + /iconv-lite/0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -11324,6 +11360,11 @@ packages: engines: {node: '>=12'} dev: false + /ky/0.29.0: + resolution: {integrity: sha512-01TBSOqlHmLfcQhHseugGHLxPtU03OyZWaLDWt5MfzCkijG6xWFvAQPhKVn0cR2MMjYvBP9keQ8A3+rQEhLO5g==} + engines: {node: '>=12'} + dev: false + /lerna/4.0.0: resolution: {integrity: sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg==} engines: {node: '>= 10.18.0'} @@ -14817,6 +14858,27 @@ packages: react: 17.0.2 dev: false + /react-i18next/11.15.4_3fb644aa30122a07f960d67fa51d6dc1: + resolution: {integrity: sha512-jKJNAcVcbPGK+yrTcXhLblgPY16n6NbpZZL3Mk8nswj1v3ayIiUBVDU09SgqnT+DluyQBS97hwSvPU5yVFG0yg==} + peerDependencies: + i18next: '>= 19.0.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.16.3 + html-escaper: 2.0.2 + html-parse-stringify: 3.0.1 + i18next: 21.6.11 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + dev: false + /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}