0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(console): application sdk integration guide

This commit is contained in:
Charles Zhao 2022-06-21 17:00:49 +08:00
parent 4905a5d72f
commit 477b5988a9
No known key found for this signature in database
GPG key ID: 4858774754C92DF2
30 changed files with 467 additions and 389 deletions

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="Install Logto Android SDK with Gradle"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
### Prerequisite
@ -54,8 +53,7 @@ dependencies {
subtitle="Configure your application and LogtoClient"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
### Configure Redirect URI
@ -76,7 +74,7 @@ Notes:
e.g. `io.logto.android://io.logto.sample/callback`
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
### Configure Logto Android SDK
@ -146,8 +144,7 @@ Notes:
subtitle="Sign In to your application by Logto and do some extra works"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
### Perform a signing-in action
@ -231,8 +228,7 @@ logtoClient.fetchUserInfo((logtoException, userInfoResponse) -> {
title="Sign Out"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
### Perform a signing-out action
@ -272,9 +268,8 @@ Notes:
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- Fetch User Info

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="从 Gradle 安装 Logto Android SDK"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
### 前提条件
@ -54,8 +53,7 @@ dependencies {
subtitle="Configure your application and LogtoClient"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
### 配置 Redirect URI
@ -76,7 +74,7 @@ $(LOGTO_REDIRECT_SCHEME)://$(YOUR_APP_PACKAGE)/callback
例: `io.logto.android://io.logto.sample/callback`
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(1)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
### 配置 Logto Android SDK
@ -146,8 +144,7 @@ Notes:
subtitle="Sign In to your application by Logto and do some extra works"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
### 执行登录
@ -231,8 +228,7 @@ logtoClient.fetchUserInfo((logtoException, userInfoResponse) -> {
title="登出"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
### 执行登出
@ -272,9 +268,8 @@ logtoClient.signOut(logtoException -> {
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- 获取用户信息

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="Add Logto SDK as a Dependency"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
Use the following URL to add Logto SDK as a dependency in Swift Package Manager.
@ -37,8 +36,7 @@ We do not support **Carthage** and **CocoaPods** at the time due to some technic
title="Init LogtoClient"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
```swift
@ -67,13 +65,12 @@ let config = try? LogtoConfig(
title="Sign In"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
First, lets configure your redirect URI
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
```swift
do {
@ -90,8 +87,7 @@ do {
subtitle="1 step"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
Calling `.signOut()` will clean all the Logto data in Keychain, if it has.
@ -106,9 +102,8 @@ await client.signOut()
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- [SDK Documentation](https://link-url-here.org)

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="Please select your favorite package manager"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
<Tabs>
<TabItem value="npm" label="npm">
@ -56,8 +55,7 @@ pnpm build
subtitle="1 step"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
Add the following code to your main html file. You may need client ID and authorization domain.
@ -71,7 +69,7 @@ import React from 'react';
const App = () => {
const config: LogtoConfig = {
clientId: 'foo',
endpoint: 'https://your-endpoint-domain.com'
endpoint: 'https://your-endpoint-domain.com',
};
return (
@ -102,15 +100,18 @@ const App = () => {
subtitle="2 steps"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
### Setup your login
### Step 1: Setup your login
The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, lets enter your redirect URI
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField
appId={props.appId}
name="redirectUris"
title="Redirect URI"
/>
Add the following code to your web app
@ -128,7 +129,7 @@ const SignInButton = () => {
export default SignInButton;
```
### Retrieve Auth Status
### Step 2: Retrieve Auth Status
```tsx
import React from "react";
@ -155,13 +156,16 @@ const App = () => {
subtitle="1 step"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
Execute signOut() methods will redirect users to the Logto sign out page. After a success sign out, all use session data and auth status will be cleared.
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" onError={() => props.onError(3)} />
<MultiTextInputField
appId={props.appId}
name="postLogoutRedirectUris"
title="Post sign out redirect URI"
/>
Add the following code to your web app
@ -185,9 +189,8 @@ export default SignOutButton;
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- [SDK Documentation](https://link-url-here.org)

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="选择您熟悉的安装方式"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
<Tabs>
<TabItem value="npm" label="npm">
@ -56,8 +55,7 @@ pnpm build
subtitle="1 step"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
在项目的 html 文件中,加入如下代码(需提前准备好 client ID 以及 authorization domain
@ -103,15 +101,14 @@ const App = () => {
subtitle="2 steps"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
### Setup your login
### Step 1: Setup your login
The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, lets enter your redirect URI
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
Add the following code to your web app
@ -129,7 +126,7 @@ const SignInButton = () => {
export default SignInButton;
```
### Retrieve Auth Status
### Step 2: Retrieve Auth Status
```tsx
import React from "react";
@ -156,13 +153,12 @@ const App = () => {
subtitle="1 step"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
Execute signOut() methods will redirect users to the Logto sign out page. After a success sign out, all use session data and auth status will be cleared.
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" onError={() => props.onError(3)} />
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" />
Add the following code to your web app
@ -186,8 +182,7 @@ export default SignOutButton;
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- [SDK Documentation](https://link-url-here.org)

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="Please select your favorite package manager"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
<Tabs>
<TabItem value="npm" label="npm">
@ -48,8 +47,7 @@ pnpm add @logto/vue
subtitle="1 step"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
`import` and use `createLogto` to install Logto plugin:
@ -74,8 +72,7 @@ app.mount("#app");
subtitle="2 steps"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
We provide two composables `useHandleSignInCallback()` and `useLogto()` which can help you easily manage the authentication flow.
@ -86,7 +83,7 @@ In order to handle what comes from Logto, the application needs to have a dedica
First, lets enter your redirect URI. E.g. `http://localhost:1234/callback`
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
Then let's create a callback component:
@ -152,8 +149,7 @@ const { isAuthenticated } = useLogto();
subtitle="1 step"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
Calling `.signOut()` will clear all the Logto data in memory and LocalStorage, if there is any.
@ -161,7 +157,7 @@ Calling `.signOut()` will clear all the Logto data in memory and LocalStorage, i
To make the user come back to your application after signing out,
it's necessary to add `http://localhost:1234` as one of the Post Sign Out URIs and use the URL as the parameter when calling `.signOut()`.
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" onError={() => props.onError(3)} />
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" />
```ts
import { useLogto } from "@logto/vue";
@ -180,9 +176,8 @@ const onClickSignOut = () => signOut('http://localhost:1234');
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- [SDK Documentation](https://link-url-here.org)

View file

@ -8,8 +8,7 @@ import TabItem from '@mdx/components/TabItem';
subtitle="请选择你喜欢的包管理工具"
index={0}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(1)}
onButtonClick={() => props.onNext(1)}
>
<Tabs>
<TabItem value="npm" label="npm">
@ -48,8 +47,7 @@ pnpm add @logto/vue
subtitle="1 step"
index={1}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(2)}
onButtonClick={() => props.onNext(2)}
>
`import` 并使用 `createLogto` 以插件的形式安装 Logto:
@ -74,8 +72,7 @@ app.mount("#app");
subtitle="2 steps"
index={2}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(3)}
onButtonClick={() => props.onNext(3)}
>
我们提供了两个组合式 API `useHandleSignInCallback()` 和 `useLogto()`,它们可以帮助你轻松完成登录认证流程。
@ -86,7 +83,7 @@ app.mount("#app");
但首先, 让我们先在下方输入 redirect URI`http://localhost:1234/callback`
<MultiTextInputField name="redirectUris" title="Redirect URI" onError={() => props.onError(2)} />
<MultiTextInputField name="redirectUris" title="Redirect URI" />
然后,让我们来创建一个 CallbackView 组件:
@ -153,15 +150,14 @@ const { isAuthenticated } = useLogto();
subtitle="1 step"
index={3}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
onNext={() => props.onNext(4)}
onButtonClick={() => props.onNext(4)}
>
调用 `.signOut()` 方法会清除所有在缓存或者 localStorage 中的 Logto 数据(如果有)。
为了确保用户登出后能够跳转回你的应用,我们需要首先在管理界面中将 `http://localhost:1234` 添加到允许登出后跳转的地址列表Post Sign Out URIs中。
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" onError={() => props.onError(3)} />
<MultiTextInputField name="postLogoutRedirectUris" title="Post sign out redirect URI" />
```ts
import { useLogto } from "@logto/vue";
@ -180,9 +176,8 @@ const onClickSignOut = () => signOut('http://localhost:1234');
subtitle="3 steps"
index={4}
activeIndex={props.activeStepIndex}
invalidIndex={props.invalidStepIndex}
buttonText="general.done"
buttonHtmlType="submit"
onButtonClick={props.onComplete}
>
- [SDK Documentation](https://link-url-here.org)

View file

@ -1,6 +1,6 @@
import { I18nKey } from '@logto/phrases';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import React, { KeyboardEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
@ -16,10 +16,11 @@ type Props = {
title: I18nKey;
value?: string[];
onChange: (value: string[]) => void;
onKeyPress?: (event: KeyboardEvent<HTMLInputElement>) => void;
error?: MultiTextInputError;
};
const MultiTextInput = ({ title, value, onChange, error }: Props) => {
const MultiTextInput = ({ title, value, onChange, onKeyPress, error }: Props) => {
const { t } = useTranslation();
const [deleteFieldIndex, setDeleteFieldIndex] = useState<number>();
@ -58,6 +59,7 @@ const MultiTextInput = ({ title, value, onChange, error }: Props) => {
onChange={({ currentTarget: { value } }) => {
handleInputChange(value, fieldIndex);
}}
onKeyPress={onKeyPress}
/>
{fieldIndex > 0 && (
<IconButton

View file

@ -1,6 +1,10 @@
@use '@/scss/underscore' as _;
.select {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 _.unit(3);
background: var(--color-layer-1);
border: 1px solid var(--color-border);
border-radius: 6px;
@ -36,18 +40,22 @@
}
.icon {
position: absolute;
right: _.unit(2);
> svg {
color: var(--color-icon);
}
display: flex;
margin-left: _.unit(3);
color: var(--color-icon);
}
.clear {
display: none;
}
.arrow {
svg {
width: 20px;
height: 20px;
}
}
&.clearable:hover {
.clear {
display: block;
@ -59,18 +67,14 @@
}
&.small {
padding: _.unit(1) _.unit(4) _.unit(1) _.unit(3);
.icon {
top: 2px;
}
height: 30px;
}
&.medium {
padding: _.unit(2) _.unit(5) _.unit(2) _.unit(3);
height: 32px;
}
.icon {
top: 6px;
}
&.large {
height: 36px;
}
}

View file

@ -14,6 +14,7 @@ type Option<T> = {
};
type Props<T> = {
className?: string;
value?: T;
options: Array<Option<T>>;
onChange?: (value?: T) => void;
@ -21,10 +22,11 @@ type Props<T> = {
hasError?: boolean;
placeholder?: ReactNode;
isClearable?: boolean;
size?: 'small' | 'medium';
size?: 'small' | 'medium' | 'large';
};
const Select = <T extends string>({
className,
value,
options,
onChange,
@ -32,7 +34,7 @@ const Select = <T extends string>({
hasError,
placeholder,
isClearable,
size = 'medium',
size = 'large',
}: Props<T>) => {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<HTMLInputElement>(null);
@ -59,7 +61,8 @@ const Select = <T extends string>({
isOpen && styles.open,
isReadOnly && styles.readOnly,
hasError && styles.error,
isClearable && value && styles.clearable
isClearable && value && styles.clearable,
className
)}
role="button"
onClick={() => {

View file

@ -0,0 +1,10 @@
@use '@/scss/underscore' as _;
.field {
width: 556px;
}
.wrapper {
display: flex;
align-items: flex-start;
}

View file

@ -1,64 +1,102 @@
import { I18nKey } from '@logto/phrases';
import { Application } from '@logto/schemas';
import React, { useRef } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import MultiTextInput from '@/components/MultiTextInput';
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
import useApi, { RequestError } from '@/hooks/use-api';
import { GuideForm } from '@/types/guide';
import { uriValidator } from '@/utilities/validator';
import * as styles from './index.module.scss';
type Props = {
appId: string;
name: 'redirectUris' | 'postLogoutRedirectUris';
title: I18nKey;
onError?: () => void;
};
const MultiTextInputField = ({ name, title, onError }: Props) => {
const MultiTextInputField = ({ appId, name, title }: Props) => {
const methods = useForm<GuideForm>();
const {
control,
formState: { errors },
} = useFormContext<GuideForm>();
getValues,
handleSubmit,
reset,
formState: { isSubmitting },
} = methods;
const { data, mutate } = useSWR<Application, RequestError>(`/api/applications/${appId}`);
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const firstErrorKey = Object.keys(errors)[0];
const isFirstErrorField = firstErrorKey && firstErrorKey === name;
const onSubmit = async (value: string[]) => {
const updatedApp = await api
.patch(`/api/applications/${appId}`, {
json: {
oidcClientMetadata: {
[name]: value.filter(Boolean),
},
},
})
.json<Application>();
void mutate(updatedApp);
if (isFirstErrorField) {
ref.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
onError?.();
}
// Reset form to set 'isDirty' to false
reset(getValues());
};
return (
<FormField isRequired title={title}>
<Controller
name={name}
control={control}
defaultValue={[]}
rules={{
validate: createValidatorForRhf({
required: t('errors.required_field_missing_plural', { field: title }),
pattern: {
verify: (value) => !value || uriValidator(value),
message: t('errors.invalid_uri_format'),
},
}),
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<div ref={ref}>
<MultiTextInput
title={title}
value={value}
error={convertRhfErrorMessage(error?.message)}
onChange={onChange}
/>
</div>
)}
/>
</FormField>
<FormProvider {...methods}>
<form>
<FormField isRequired className={styles.field} title={title}>
<Controller
name={name}
control={control}
defaultValue={data?.oidcClientMetadata[name]}
rules={{
validate: createValidatorForRhf({
required: t('errors.required_field_missing_plural', { field: title }),
pattern: {
verify: (value) => !value || uriValidator(value),
message: t('errors.invalid_uri_format'),
},
}),
}}
render={({ field: { onChange, value }, fieldState: { error, isDirty } }) => (
<div ref={ref} className={styles.wrapper}>
<MultiTextInput
title={title}
value={value}
error={convertRhfErrorMessage(error?.message)}
onChange={onChange}
onKeyPress={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSubmit(async () => onSubmit(value))();
}
}}
/>
<Button
disabled={!isDirty}
isLoading={isSubmitting}
title="general.save"
type="primary"
onClick={handleSubmit(async () => onSubmit(value))}
/>
</div>
)}
/>
</FormField>
</form>
</FormProvider>
);
};

View file

@ -29,15 +29,40 @@
.content {
margin-top: 0;
max-height: 0;
height: 0;
overflow: hidden;
&.expanded {
margin-top: _.unit(6);
max-height: 9999px;
height: auto;
overflow: unset;
}
}
ul {
padding-inline-start: 1ch;
}
li {
list-style-type: '-';
margin-block-end: _.unit(6);
padding-inline-start: _.unit(1.5);
}
a {
font: var(--font-body-medium);
color: var(--color-text-link);
}
h3 {
font: var(--font-title-medium);
color: var(--color-caption);
margin: _.unit(6) 0;
}
p {
font: var(--font-body-medium);
margin: _.unit(6) 0;
}
}
.card + .card {

View file

@ -1,5 +1,4 @@
import { I18nKey } from '@logto/phrases';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
@ -19,10 +18,8 @@ type Props = PropsWithChildren<{
subtitle?: string;
index: number;
activeIndex: number;
invalidIndex?: number;
buttonText?: I18nKey;
buttonHtmlType: 'submit' | 'button';
onNext?: () => void;
onButtonClick?: () => void;
}>;
const Step = ({
@ -31,22 +28,19 @@ const Step = ({
subtitle,
index,
activeIndex,
invalidIndex,
buttonText = 'general.next',
buttonHtmlType = 'button',
onNext,
onButtonClick,
}: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
const isActive = index === activeIndex;
const isComplete = index < activeIndex;
const isInvalid = index === invalidIndex;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive || isInvalid) {
if (isActive) {
setIsExpanded(true);
}
}, [isActive, isInvalid]);
}, [isActive]);
useEffect(() => {
if (isExpanded) {
@ -79,12 +73,7 @@ const Step = ({
<div className={classNames(styles.content, isExpanded && styles.expanded)}>
{children}
<div className={styles.buttonWrapper}>
<Button
htmlType={buttonHtmlType}
type="primary"
title={buttonText}
onClick={conditional(onNext)}
/>
<Button type="outline" size="large" title={buttonText} onClick={onButtonClick} />
</div>
</div>
</Card>

View file

@ -2,6 +2,7 @@
.container {
width: 100%;
margin-top: _.unit(6);
ul {
border-bottom: 1px solid var(--color-divider);
@ -15,6 +16,8 @@
padding-bottom: _.unit(1);
font: var(--font-subhead-2);
color: var(--color-caption);
margin-block-end: unset;
padding-inline-start: unset;
cursor: pointer;
}

View file

@ -2,6 +2,7 @@ import { Application, ApplicationType } from '@logto/schemas';
import React, { useState } from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
@ -10,10 +11,10 @@ import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import { GuideForm } from '@/types/guide';
import GuideModal from '../GuideModal';
import Guide from '../Guide';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
@ -55,30 +56,10 @@ const CreateForm = ({ onClose }: Props) => {
const createdApp = await api.post('/api/applications', { json: data }).json<Application>();
setCreatedApp(createdApp);
setIsGetStartedModalOpen(true);
void updateSettings({ createApplication: true });
});
const onComplete = async (data: GuideForm) => {
if (!createdApp) {
return;
}
const application = await api
.patch(`/api/applications/${createdApp.id}`, {
json: {
oidcClientMetadata: {
redirectUris: data.redirectUris.filter(Boolean),
postLogoutRedirectUris: data.postLogoutRedirectUris.filter(Boolean),
},
},
})
.json<Application>();
await updateSettings({ createApplication: true });
setCreatedApp(application);
closeModal();
};
return (
<ModalLayout
title="applications.create"
@ -128,13 +109,9 @@ const CreateForm = ({ onClose }: Props) => {
</FormField>
</form>
{createdApp && (
<GuideModal
appName={createdApp.name}
appType={createdApp.type}
isOpen={isGetStartedModalOpen}
onClose={closeModal}
onComplete={onComplete}
/>
<Modal isOpen={isGetStartedModalOpen} className={modalStyles.fullScreen}>
<Guide app={createdApp} onClose={closeModal} />
</Modal>
)}
</ModalLayout>
);

View file

@ -0,0 +1,32 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
background-color: var(--color-base);
height: 100vh;
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
padding: _.unit(6) _.unit(6) _.unit(20);
> * {
max-width: 858px;
width: 100%;
}
.banner {
display: flex;
align-items: center;
margin-bottom: _.unit(6);
}
}
}
.markdownContent {
margin-top: _.unit(6);
}

View file

@ -0,0 +1,87 @@
import { Application } from '@logto/schemas';
import { MDXProvider } from '@mdx-js/react';
import i18next from 'i18next';
import { MDXProps } from 'mdx/types';
import React, { cloneElement, lazy, LazyExoticComponent, Suspense, useState } from 'react';
import CodeEditor from '@/components/CodeEditor';
import { applicationTypeAndSdkTypeMappings, SupportedSdk } from '@/types/applications';
import GuideHeader from '../GuideHeader';
import SdkSelector from '../SdkSelector';
import StepsSkeleton from '../StepsSkeleton';
import * as styles from './index.module.scss';
type Props = {
app: Application;
isCompact?: boolean;
onClose: () => void;
};
const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Element>> = {
ios: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/ios.mdx')),
android: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/android.mdx')),
react: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/react.mdx')),
vue: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vue.mdx')),
'android_zh-cn': lazy(
async () => import('@/assets/docs/tutorial/integrate-sdk/android_zh-cn.mdx')
),
'react_zh-cn': lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/react_zh-cn.mdx')),
'vue_zh-cn': lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vue_zh-cn.mdx')),
};
const Guide = ({ app, isCompact, onClose }: Props) => {
const { id: appId, name: appName, type: appType } = app;
const sdks = applicationTypeAndSdkTypeMappings[appType];
const [selectedSdk, setSelectedSdk] = useState<SupportedSdk>(sdks[0]);
const [activeStepIndex, setActiveStepIndex] = useState(-1);
const locale = i18next.language;
const guideI18nKey = `${selectedSdk}_${locale}`.toLowerCase();
const GuideComponent = Guides[guideI18nKey] ?? Guides[selectedSdk.toLowerCase()];
return (
<div className={styles.container}>
<GuideHeader
appName={appName}
selectedSdk={selectedSdk}
isCompact={isCompact}
onClose={onClose}
/>
<div className={styles.content}>
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
className: styles.banner,
isCompact,
onChange: setSelectedSdk,
onToggle: () => {
setActiveStepIndex(0);
},
})}
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
return <CodeEditor isReadonly language={language} value={String(children)} />;
},
}}
>
<Suspense fallback={<StepsSkeleton />}>
{GuideComponent && (
<GuideComponent
appId={appId}
activeStepIndex={activeStepIndex}
onNext={(nextIndex: number) => {
setActiveStepIndex(nextIndex);
}}
onComplete={onClose}
/>
)}
</Suspense>
</MDXProvider>
</div>
</div>
);
};
export default Guide;

View file

@ -0,0 +1,23 @@
@use '@/scss/underscore' as _;
.header {
display: flex;
align-items: center;
background-color: var(--color-layer-1);
height: 64px;
padding: 0 _.unit(6);
.separator {
@include _.vertical-bar;
height: 20px;
margin: 0 _.unit(5) 0 _.unit(4);
}
.closeIcon {
color: var(--color-icon);
}
.getSampleButton {
margin-right: _.unit(15);
}
}

View file

@ -0,0 +1,67 @@
import React from 'react';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import Close from '@/icons/Close';
import * as styles from './index.module.scss';
type Props = {
appName: string;
selectedSdk: string;
isCompact?: boolean;
onClose: () => void;
};
const onClickFetchSampleProject = (projectName: string) => {
const sampleUrl = `https://github.com/logto-io/js/tree/master/packages/${projectName}-sample`;
window.open(sampleUrl, '_blank');
};
const GuideHeader = ({ appName, selectedSdk, isCompact = false, onClose }: Props) => {
return (
<div className={styles.header}>
{isCompact && (
<>
<CardTitle
size="small"
title={<DangerousRaw>{appName}</DangerousRaw>}
subtitle="applications.guide.header_description"
/>
<Spacer />
<IconButton size="large" onClick={onClose}>
<Close className={styles.closeIcon} />
</IconButton>
</>
)}
{!isCompact && (
<>
<IconButton size="large" onClick={onClose}>
<Close className={styles.closeIcon} />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title={<DangerousRaw>{appName}</DangerousRaw>}
subtitle="applications.guide.header_description"
/>
<Spacer />
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
<Button
className={styles.getSampleButton}
type="outline"
title="admin_console.applications.guide.get_sample_file"
onClick={() => {
onClickFetchSampleProject(selectedSdk);
}}
/>
</>
)}
</div>
);
};
export default GuideHeader;

View file

@ -1,51 +0,0 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
background-color: var(--color-base);
height: 100vh;
.header {
display: flex;
align-items: center;
background-color: var(--color-layer-1);
height: 64px;
padding: 0 _.unit(21) 0 _.unit(2);
button {
margin-left: _.unit(4);
}
.separator {
@include _.vertical-bar;
height: 20px;
margin: 0 _.unit(5) 0 _.unit(4);
}
.closeIcon {
color: var(--color-icon);
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
padding: _.unit(6) 0 _.unit(20);
> * {
width: 858px;
}
.banner {
margin-bottom: _.unit(6);
}
}
}
.markdownContent {
margin-top: _.unit(6);
}

View file

@ -1,137 +0,0 @@
import { ApplicationType } from '@logto/schemas';
import { MDXProvider } from '@mdx-js/react';
import i18next from 'i18next';
import { MDXProps } from 'mdx/types';
import React, { cloneElement, lazy, LazyExoticComponent, Suspense, useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import Modal from 'react-modal';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import CodeEditor from '@/components/CodeEditor';
import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import Close from '@/icons/Close';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeAndSdkTypeMappings, SupportedSdk } from '@/types/applications';
import { GuideForm } from '@/types/guide';
import SdkSelector from '../SdkSelector';
import StepsSkeleton from '../StepsSkeleton';
import * as styles from './index.module.scss';
type Props = {
appName: string;
appType: ApplicationType;
isOpen: boolean;
onClose: () => void;
onComplete: (data: GuideForm) => Promise<void>;
};
const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Element>> = {
ios: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/ios.mdx')),
android: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/android.mdx')),
react: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/react.mdx')),
vue: lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vue.mdx')),
'android_zh-cn': lazy(
async () => import('@/assets/docs/tutorial/integrate-sdk/android_zh-cn.mdx')
),
'react_zh-cn': lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/react_zh-cn.mdx')),
'vue_zh-cn': lazy(async () => import('@/assets/docs/tutorial/integrate-sdk/vue_zh-cn.mdx')),
};
const onClickFetchSampleProject = (projectName: string) => {
const sampleUrl = `https://github.com/logto-io/js/tree/master/packages/${projectName}-sample`;
window.open(sampleUrl, '_blank');
};
const GuideModal = ({ appName, appType, isOpen, onClose, onComplete }: Props) => {
const sdks = applicationTypeAndSdkTypeMappings[appType];
const [selectedSdk, setSelectedSdk] = useState<SupportedSdk>(sdks[0]);
const [activeStepIndex, setActiveStepIndex] = useState(-1);
const [invalidStepIndex, setInvalidStepIndex] = useState(-1);
const locale = i18next.language;
const guideI18nKey = `${selectedSdk}_${locale}`.toLowerCase();
const GuideComponent = Guides[guideI18nKey] ?? Guides[selectedSdk.toLowerCase()];
const methods = useForm<GuideForm>({ mode: 'onSubmit', reValidateMode: 'onChange' });
const {
formState: { isSubmitting },
handleSubmit,
} = methods;
const onSubmit = handleSubmit((data) => {
if (isSubmitting) {
return;
}
void onComplete(data);
});
return (
<Modal isOpen={isOpen} className={modalStyles.fullScreen}>
<div className={styles.container}>
<div className={styles.header}>
<IconButton size="large" onClick={onClose}>
<Close className={styles.closeIcon} />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title={<DangerousRaw>{appName}</DangerousRaw>}
subtitle="applications.guide.header_description"
/>
<Spacer />
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
<Button
type="outline"
title="admin_console.applications.guide.get_sample_file"
onClick={() => {
onClickFetchSampleProject(selectedSdk);
}}
/>
</div>
<div className={styles.content}>
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
className: styles.banner,
onChange: setSelectedSdk,
onToggle: () => {
setActiveStepIndex(0);
},
})}
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
return <CodeEditor isReadonly language={language} value={String(children)} />;
},
}}
>
<Suspense fallback={<StepsSkeleton />}>
{GuideComponent && (
<GuideComponent
activeStepIndex={activeStepIndex}
invalidStepIndex={invalidStepIndex}
onNext={(nextIndex: number) => {
setActiveStepIndex(nextIndex);
}}
onError={(invalidIndex: number) => {
setInvalidStepIndex(invalidIndex);
}}
/>
)}
</Suspense>
</MDXProvider>
</form>
</FormProvider>
</div>
</div>
</Modal>
);
};
export default GuideModal;

View file

@ -31,6 +31,11 @@
max-width: unset;
}
.select {
background: var(--color-guide-dropdown-background);
border-color: var(--color-guide-dropdown-border);
}
&.folded {
display: flex;
flex-direction: row;
@ -44,11 +49,12 @@
color: var(--color-text);
img {
margin-right: _.unit(3);
margin: 0 _.unit(4) 0 0;
}
}
.buttonWrapper {
width: 100%;
display: flex;
justify-content: flex-end;
margin-top: _.unit(6);

View file

@ -7,6 +7,8 @@ import tada from '@/assets/images/tada.svg';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Select from '@/components/Select';
import Spacer from '@/components/Spacer';
import { SupportedSdk } from '@/types/applications';
import * as styles from './index.module.scss';
@ -15,12 +17,20 @@ type Props = {
className?: string;
sdks: readonly SupportedSdk[];
selectedSdk: SupportedSdk;
isCompact?: boolean;
onChange?: (value: string) => void;
onToggle?: () => void;
};
const SdkSelector = ({ className, sdks, selectedSdk, onChange, onToggle }: Props) => {
const [isFolded, setIsFolded] = useState(false);
const SdkSelector = ({
className,
sdks,
selectedSdk,
isCompact = false,
onChange,
onToggle,
}: Props) => {
const [isFolded, setIsFolded] = useState(isCompact);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
if (isFolded) {
@ -28,6 +38,16 @@ const SdkSelector = ({ className, sdks, selectedSdk, onChange, onToggle }: Props
<div className={classNames(styles.card, styles.folded, className)}>
<img src={tada} alt="Tada!" />
<span>{t('applications.guide.description_by_sdk', { sdk: selectedSdk })}</span>
<Spacer />
<Select
className={styles.select}
value={selectedSdk}
size="medium"
options={sdks.map((sdk) => ({ value: sdk, title: sdk }))}
onChange={(value) => {
onChange?.(value ?? selectedSdk);
}}
/>
</div>
);
}
@ -55,8 +75,9 @@ const SdkSelector = ({ className, sdks, selectedSdk, onChange, onToggle }: Props
</RadioGroup>
<div className={styles.buttonWrapper}>
<Button
type="primary"
type="outline"
title="general.next"
size="large"
onClick={() => {
setIsFolded(true);
onToggle?.();

View file

@ -125,7 +125,6 @@ const GuideModal = ({ connector, isOpen, onClose }: Props) => {
subtitle="Lorem ipsum dolor sit amet, consectetuer adipiscing elit."
index={0}
activeIndex={activeStepIndex}
buttonHtmlType="submit"
buttonText={steps === 1 ? 'general.done' : undefined}
>
<Controller
@ -145,9 +144,8 @@ const GuideModal = ({ connector, isOpen, onClose }: Props) => {
subtitle="Lorem ipsum dolor sit amet, consectetuer adipiscing elit."
index={1}
activeIndex={activeStepIndex}
buttonHtmlType="button"
buttonText="general.done"
onNext={onClose}
onButtonClick={onClose}
>
<SenderTester connectorType={connectorType} />
</Step>

View file

@ -44,7 +44,7 @@ const updateApplication = buildUpdateWhere<CreateApplication, Application>(Appli
export const updateApplicationById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateApplication>>
) => updateApplication({ set, where: { id }, jsonbMode: 'replace' });
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
export const deleteApplicationById = async (id: string) => {
const { rowCount } = await envSet.pool.query(sql`

View file

@ -75,7 +75,7 @@ export default function applicationRoutes<T extends AuthedRouter>(router: T) {
'/applications/:id',
koaGuard({
params: object({ id: string().min(1) }),
body: Applications.createGuard.omit({ id: true, createdAt: true }).partial(),
body: Applications.createGuard.omit({ id: true, createdAt: true }).deepPartial(),
}),
async (ctx, next) => {
const {

View file

@ -7,6 +7,7 @@ const translation = {
retry: 'Try again',
done: 'Done',
search: 'Search',
save: 'Save',
save_changes: 'Save Changes',
loading: 'Loading...',
redirecting: 'Redirecting...',

View file

@ -9,6 +9,7 @@ const translation = {
retry: '重试',
done: '完成',
search: '搜索',
save: '保存',
save_changes: '保存更改',
loading: '读取中...',
redirecting: '页面跳转中...',

View file

@ -160,6 +160,9 @@
--color-tooltip-background: #34353f; // dark theme Surface-4
--color-tooltip-text: var(--color-neutral-99);
--color-overlay: rgba(0, 0, 0, 30%);
--color-drawer-overlay: rgba(0, 0, 0, 40%);
--color-guide-dropdown-background: var(--color-white);
--color-guide-dropdown-border: var(--color-border);
--color-skeleton-shimmer-rgb: 255, 255, 255; // rgb of Layer-1
}
@ -325,5 +328,8 @@
--color-tooltip-background: var(--color-surface-4);
--color-tooltip-text: var(--color-neutral-10);
--color-overlay: rgba(0, 0, 0, 30%);
--color-drawer-overlay: rgba(0, 0, 0, 60%);
--color-guide-dropdown-background: var(--color-neutral-variant-80);
--color-guide-dropdown-border: var(--color-neutral-variant-70);
--color-skeleton-shimmer-rgb: 42, 44, 50; // rgb of Layer-1
}