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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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==}