0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(ui): migrate files 1/2 (#251)

This commit is contained in:
Gao Sun 2022-02-18 17:11:54 +08:00 committed by GitHub
parent 9e30b41028
commit 46d1fee353
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1011 additions and 10 deletions

View file

@ -28,5 +28,8 @@
"engines": { "engines": {
"node": ">=14.15.0", "node": ">=14.15.0",
"pnpm": ">=6" "pnpm": ">=6"
},
"alias": {
"html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js"
} }
} }

View file

@ -15,6 +15,8 @@
"stylelint": "stylelint \"src/**/*.scss\"" "stylelint": "stylelint \"src/**/*.scss\""
}, },
"dependencies": { "dependencies": {
"@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2" "react-dom": "^17.0.2"
}, },

View file

@ -25,6 +25,7 @@
"url": "https://github.com/logto-io/logto/issues" "url": "https://github.com/logto-io/logto/issues"
}, },
"dependencies": { "dependencies": {
"@logto/schemas": "^0.1.0",
"@silverhand/essentials": "^1.1.4" "@silverhand/essentials": "^1.1.4"
}, },
"devDependencies": { "devDependencies": {

View file

@ -8,13 +8,20 @@
"precommit": "lint-staged", "precommit": "lint-staged",
"start": "parcel src/index.html", "start": "parcel src/index.html",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall", "build": "rm -rf dist && parcel build src/index.html --no-autoinstall",
"lint": "eslint --ext .ts --ext .tsx src",
"stylelint": "stylelint \"src/**/*.scss\"" "stylelint": "stylelint \"src/**/*.scss\""
}, },
"dependencies": { "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": "^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": { "devDependencies": {
"@parcel/core": "^2.3.1", "@parcel/core": "^2.3.1",
@ -25,6 +32,7 @@
"@silverhand/ts-config-react": "^0.8.1", "@silverhand/ts-config-react": "^0.8.1",
"@types/react": "^17.0.14", "@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9", "@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.3.2",
"eslint": "^8.1.0", "eslint": "^8.1.0",
"lint-staged": "^11.1.1", "lint-staged": "^11.1.1",
"parcel": "^2.3.1", "parcel": "^2.3.1",
@ -33,6 +41,9 @@
"prettier": "^2.3.2", "prettier": "^2.3.2",
"stylelint": "^13.13.1" "stylelint": "^13.13.1"
}, },
"alias": {
"@/*": "./src/$1"
},
"eslintConfig": { "eslintConfig": {
"extends": "@silverhand/react" "extends": "@silverhand/react"
}, },

View file

@ -1,3 +0,0 @@
.app {
color: #aaa;
}

View file

@ -1,7 +1,30 @@
import React from 'react'; 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 = () => { void initI18n();
return <h1 className={styles.app}>Hello world!</h1>;
const App = () => {
const theme = useTheme();
return (
<AppContent theme={theme}>
<BrowserRouter>
<Switch>
<Route exact path="/sign-in" component={SignIn} />
<Route exact path="/sign-in/consent" component={Consent} />
<Route exact path="/register" component={Register} />
</Switch>
</BrowserRouter>
</AppContent >
);
}; };
export default App;

View file

@ -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) => <Component t={(k) => 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,
};

View file

@ -0,0 +1,9 @@
import ky from 'ky';
export const consent = async () => {
type Response = {
redirectTo: string;
};
return ky.post('/api/session/consent').json<Response>();
};

View file

@ -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<Response>();
};

View file

@ -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<Response>();
};

View file

@ -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};
}

View file

@ -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 (
<div className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
{children}
</div>
);
};
export default AppContent;

View file

@ -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);
}
}

View file

@ -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 (
<input
disabled={isDisabled}
className={classNames(styles.button, className)}
type="button"
value={value}
onClick={onClick}
/>
);
};
export default Button;

View file

@ -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);
}
}

View file

@ -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 (
<input
name={name}
disabled={isDisabled}
className={classNames(styles.input, className)}
placeholder={placeholder}
type={type}
value={value}
autoComplete={autoComplete}
onChange={({ target: { value } }) => {
onChange(value);
}}
/>
);
};
export default Input;

View file

@ -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);
}
}

View file

@ -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 <div className={classNames(styles.messageBox, styles.error, className)}>{children}</div>;
};
export default MessageBox;

View file

@ -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);
}
}

View file

@ -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 (
<a className={classNames(styles.link, className)} href={href}>
{children}
</a>
);
};
export default TextLink;

View file

@ -0,0 +1,52 @@
import { RequestErrorBody } from '@logto/schemas';
import { HTTPError } from 'ky';
import { useState, useCallback } from 'react';
type UseApi<T extends any[], U> = {
result?: U;
loading: boolean;
error: RequestErrorBody | null;
run: (...args: T) => Promise<void>;
};
function useApi<Args extends any[], Response>(
api: (...args: Args) => Promise<Response>
): UseApi<Args, Response> {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<RequestErrorBody | null>(null);
const [result, setResult] = useState<Response>();
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<RequestErrorBody>();
setError(kyError);
setLoading(false);
return;
}
setLoading(false);
throw error;
}
},
[api]
);
return {
loading,
error,
result,
run,
};
}
export default useApi;

View file

@ -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;
}

View file

@ -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;

76
packages/ui-new/src/include.d/dom.d.ts vendored Normal file
View file

@ -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<T>(): Promise<T>;
}

View file

@ -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;
}
}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { App } from './App'; import App from './App';
const app = document.querySelector('#app'); const app = document.querySelector('#app');
ReactDOM.render(<App />, app); ReactDOM.render(<App />, app);

View file

@ -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 <div>{t('sign_in.loading')}</div>;
};
export default Consent;

View file

@ -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);
}
}

View file

@ -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('<Register />', () => {
test('renders without exploding', async () => {
const { queryByText, getByText } = render(<Register />);
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();
});
});
});

View file

@ -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 (
<div className={classNames(styles.wrapper)}>
<form className={classNames(styles.form)}>
<div className={styles.title}>{t('register.create_account')}</div>
<Input
name="username"
isDisabled={loading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername} // TODO: account validation
/>
<Input
name="password"
isDisabled={loading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword} // TODO: password validation
/>
{error && (
<MessageBox className={styles.box}>
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
</MessageBox>
)}
<Button
isDisabled={loading}
value={loading ? t('register.loading') : t('register.action')}
onClick={signUp}
/>
<div className={styles.haveAccount}>
<span className={styles.prefix}>{t('register.have_account')}</span>
<TextLink href="/sign-in">{t('sign_in.action')}</TextLink>
</div>
</form>
</div>
);
};
export default Register;

View file

@ -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);
}
}

View file

@ -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('<SignIn />', () => {
test('renders without exploding', async () => {
const { queryByText, getByText } = render(<SignIn />);
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();
});
});
});

View file

@ -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 (
<div className={classNames(styles.wrapper)}>
<form className={classNames(styles.form)}>
<div className={styles.title}>Sign in to Logto</div>
<Input
name="username"
autoComplete="username"
isDisabled={loading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername}
/>
<Input
name="password"
autoComplete="current-password"
isDisabled={loading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword}
/>
{error && (
<MessageBox className={styles.box}>
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
</MessageBox>
)}
<Button
isDisabled={loading}
value={loading ? t('sign_in.loading') : t('sign_in.action')}
onClick={signInHandler}
/>
<TextLink className={styles.createAccount} href="/register">
{t('register.create_account')}
</TextLink>
</form>
</div>
);
};
export default SignIn;

View file

@ -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;
}

View file

@ -0,0 +1,14 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
input {
border: none;
outline: none;
}

View file

@ -1,5 +1,12 @@
{ {
"extends": "@silverhand/ts-config-react/tsconfig.base", "extends": "@silverhand/ts-config-react/tsconfig.base",
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [ "include": [
"src" "src"
] ]

View file

@ -20,6 +20,8 @@ importers:
packages/console: packages/console:
specifiers: specifiers:
'@logto/phrases': ^0.1.0
'@logto/schemas': ^0.1.0
'@parcel/core': ^2.3.1 '@parcel/core': ^2.3.1
'@parcel/transformer-sass': ^2.3.1 '@parcel/transformer-sass': ^2.3.1
'@silverhand/eslint-config': ^0.8.1 '@silverhand/eslint-config': ^0.8.1
@ -39,6 +41,8 @@ importers:
stylelint: ^13.13.1 stylelint: ^13.13.1
typescript: ^4.5.5 typescript: ^4.5.5
dependencies: dependencies:
'@logto/phrases': link:../phrases
'@logto/schemas': link:../schemas
react: 17.0.2 react: 17.0.2
react-dom: 17.0.2_react@17.0.2 react-dom: 17.0.2_react@17.0.2
devDependencies: devDependencies:
@ -166,6 +170,7 @@ importers:
packages/phrases: packages/phrases:
specifiers: specifiers:
'@logto/schemas': ^0.1.0
'@silverhand/eslint-config': ^0.8.1 '@silverhand/eslint-config': ^0.8.1
'@silverhand/essentials': ^1.1.4 '@silverhand/essentials': ^1.1.4
'@silverhand/ts-config': ^0.8.1 '@silverhand/ts-config': ^0.8.1
@ -174,6 +179,7 @@ importers:
prettier: ^2.3.2 prettier: ^2.3.2
typescript: ^4.5.5 typescript: ^4.5.5
dependencies: dependencies:
'@logto/schemas': link:../schemas
'@silverhand/essentials': 1.1.4 '@silverhand/essentials': 1.1.4
devDependencies: devDependencies:
'@silverhand/eslint-config': 0.8.1_b07be603d0ceb19daeedad1772e0f2c4 '@silverhand/eslint-config': 0.8.1_b07be603d0ceb19daeedad1772e0f2c4
@ -309,6 +315,8 @@ importers:
packages/ui-new: packages/ui-new:
specifiers: specifiers:
'@logto/phrases': ^0.1.0
'@logto/schemas': ^0.1.0
'@parcel/core': ^2.3.1 '@parcel/core': ^2.3.1
'@parcel/transformer-sass': ^2.3.1 '@parcel/transformer-sass': ^2.3.1
'@silverhand/eslint-config': ^0.8.1 '@silverhand/eslint-config': ^0.8.1
@ -317,7 +325,12 @@ importers:
'@silverhand/ts-config-react': ^0.8.1 '@silverhand/ts-config-react': ^0.8.1
'@types/react': ^17.0.14 '@types/react': ^17.0.14
'@types/react-dom': ^17.0.9 '@types/react-dom': ^17.0.9
'@types/react-router-dom': ^5.3.2
classnames: ^2.3.1
eslint: ^8.1.0 eslint: ^8.1.0
i18next: ^21.6.11
i18next-browser-languagedetector: ^6.1.3
ky: ^0.29.0
lint-staged: ^11.1.1 lint-staged: ^11.1.1
parcel: ^2.3.1 parcel: ^2.3.1
postcss: ^8.4.6 postcss: ^8.4.6
@ -325,10 +338,20 @@ importers:
prettier: ^2.3.2 prettier: ^2.3.2
react: ^17.0.2 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
stylelint: ^13.13.1 stylelint: ^13.13.1
dependencies: 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: 17.0.2
react-dom: 17.0.2_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: devDependencies:
'@parcel/core': 2.3.1 '@parcel/core': 2.3.1
'@parcel/transformer-sass': 2.3.1_@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 '@silverhand/ts-config-react': 0.8.1_typescript@4.5.5
'@types/react': 17.0.37 '@types/react': 17.0.37
'@types/react-dom': 17.0.11 '@types/react-dom': 17.0.11
'@types/react-router-dom': 5.3.2
eslint: 8.4.1 eslint: 8.4.1
lint-staged: 11.2.6 lint-staged: 11.2.6
parcel: 2.3.1_postcss@8.4.6 parcel: 2.3.1_postcss@8.4.6
@ -9248,12 +9272,24 @@ packages:
'@babel/runtime': 7.16.3 '@babel/runtime': 7.16.3
dev: false 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: /i18next/20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==} resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
dependencies: dependencies:
'@babel/runtime': 7.16.3 '@babel/runtime': 7.16.3
dev: false 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: /iconv-lite/0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -11324,6 +11360,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/ky/0.29.0:
resolution: {integrity: sha512-01TBSOqlHmLfcQhHseugGHLxPtU03OyZWaLDWt5MfzCkijG6xWFvAQPhKVn0cR2MMjYvBP9keQ8A3+rQEhLO5g==}
engines: {node: '>=12'}
dev: false
/lerna/4.0.0: /lerna/4.0.0:
resolution: {integrity: sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg==} resolution: {integrity: sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg==}
engines: {node: '>= 10.18.0'} engines: {node: '>= 10.18.0'}
@ -14817,6 +14858,27 @@ packages:
react: 17.0.2 react: 17.0.2
dev: false 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: /react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}