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

feat: demo app dev panel (#6105)

This commit is contained in:
Gao Sun 2024-07-01 13:18:04 +08:00 committed by GitHub
parent 01558bbddf
commit f78b1768ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 383 additions and 73 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/demo-app": minor
---
add dev panel

View file

@ -33,7 +33,7 @@
"@logto/language-kit": "workspace:^1.1.0",
"@logto/phrases": "workspace:^1.11.0",
"@logto/phrases-experience": "workspace:^1.6.1",
"@logto/react": "^3.0.8",
"@logto/react": "^3.0.11",
"@logto/schemas": "workspace:^1.17.0",
"@logto/shared": "workspace:^3.1.1",
"@mdx-js/mdx": "^3.0.1",

View file

@ -22,7 +22,7 @@
"@logto/core-kit": "workspace:^2.4.0",
"@logto/language-kit": "workspace:^1.1.0",
"@logto/phrases": "workspace:^1.10.0",
"@logto/react": "^3.0.8",
"@logto/react": "^3.0.11",
"@logto/schemas": "workspace:^1.15.0",
"@parcel/core": "2.9.3",
"@parcel/transformer-sass": "2.9.3",
@ -37,6 +37,7 @@
"eslint": "^8.56.0",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^8.0.0",
"jose": "^5.0.0",
"lint-staged": "^15.0.0",
"parcel": "2.9.3",
"postcss": "^8.4.31",

View file

@ -3,40 +3,113 @@
@use '@logto/core-kit/scss/console-themes' as themes;
.app {
input {
background-color: var(--color-layer-1);
font: var(--font-body-2);
color: var(--color-text);
padding: 0 _.unit(3);
height: 36px;
border: 1px solid var(--color-border);
outline: 3px solid transparent;
border-radius: 6px;
}
.button {
display: inline-block;
user-select: none;
border: 1px solid var(--color-border);
background-color: var(--color-layer-1);
border-radius: 8px;
padding: _.unit(3) _.unit(6);
font: var(--font-label-2);
color: var(--color-text);
transition: background ease-in-out 0.2s;
&:hover {
cursor: pointer;
background: var(--color-hover);
}
&:focus {
outline: 3px solid var(--color-focused-variant);
}
}
.card {
background: var(--color-layer-1);
border-radius: 16px;
position: absolute;
left: 50%;
top: 50%;
width: 640px;
height: 640px;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-size: 14px;
line-height: 20px;
img {
margin-top: _.unit(25);
width: 120px;
height: 120px;
}
.title {
margin-top: _.unit(6);
color: var(--color-neutral-10);
font: var(--font-title-2);
}
.text {
margin-top: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
&.congrats {
position: absolute;
left: 50%;
top: 50%;
align-items: center;
text-align: center;
width: 640px;
height: 640px;
transform: translate(-50%, -50%);
.title {
margin-top: _.unit(6);
}
.text {
margin-top: _.unit(1);
}
.button {
margin-top: _.unit(8);
}
}
&.devPanel {
max-width: 800px;
width: 25vw;
position: fixed;
left: _.unit(2);
top: _.unit(2);
padding: _.unit(4);
gap: _.unit(3);
.item {
margin: _.unit(2) 0;
display: flex;
flex-direction: column;
gap: _.unit(1);
}
.button {
align-self: flex-end;
}
.action {
display: flex;
justify-content: space-between;
align-items: center;
}
}
img {
margin-top: _.unit(25);
width: 120px;
height: 120px;
}
.infoCard {
margin-top: _.unit(4);
padding: _.unit(4);
@ -61,25 +134,6 @@
}
}
.button {
user-select: none;
margin-top: _.unit(8);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: _.unit(3) _.unit(6);
font: var(--font-label-2);
color: var(--color-text);
transition: background ease-in-out 0.2s;
&:hover {
cursor: pointer;
background: var(--color-hover);
}
&:focus {
outline: 3px solid var(--color-focused-variant);
}
}
.continue {
margin-top: _.unit(12);
@ -121,6 +175,11 @@
}
}
.error {
color: var(--color-neutral-10);
margin: _.unit(3);
}
@media (prefers-color-scheme: light) {
body {
@include themes.light;

View file

@ -1,28 +1,41 @@
import type { IdTokenClaims } from '@logto/react';
import { LogtoProvider, useLogto, Prompt, UserScope } from '@logto/react';
import { type IdTokenClaims, LogtoProvider, useLogto, type Prompt } from '@logto/react';
import { demoAppApplicationId } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import '@/scss/normalized.scss';
import * as styles from './App.module.scss';
import Callback from './Callback';
import DevPanel from './DevPanel';
import congratsDark from './assets/congrats-dark.svg';
import congrats from './assets/congrats.svg';
import initI18n from './i18n/init';
import { getLocalData, setLocalData } from './utils';
void initI18n();
const Main = () => {
const params = new URL(window.location.href).searchParams;
const { isAuthenticated, isLoading, getIdTokenClaims, signIn, signOut } = useLogto();
const [user, setUser] = useState<Pick<IdTokenClaims, 'sub' | 'username'>>();
const { t } = useTranslation(undefined, { keyPrefix: 'demo_app' });
const isInCallback = Boolean(new URL(window.location.href).searchParams.get('code'));
const isInCallback = Boolean(params.get('code'));
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const [congratsIcon, setCongratsIcon] = useState<string>(isDarkMode ? congratsDark : congrats);
const [showDevPanel, setShowDevPanel] = useState(getLocalData('ui').showDevPanel ?? false);
const error = params.get('error');
const errorDescription = params.get('error_description');
const toggleDevPanel = useCallback(() => {
setShowDevPanel((previous) => {
setLocalData('ui', { showDevPanel: !previous });
return !previous;
});
}, []);
useEffect(() => {
if (isInCallback || isLoading) {
if (isInCallback || isLoading || error) {
return;
}
@ -43,7 +56,7 @@ const Main = () => {
extraParams: Object.fromEntries(new URLSearchParams(window.location.search).entries()),
});
}
}, [getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]);
}, [error, getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]);
useEffect(() => {
const onThemeChange = (event: MediaQueryListEvent) => {
@ -64,13 +77,37 @@ const Main = () => {
return <Callback />;
}
if (error) {
return (
<div className={styles.app}>
<div className={styles.error}>
<p>
Error occurred: {error}
<br />
{errorDescription}
</p>
<button
className={styles.button}
onClick={() => {
setLocalData('config', {});
window.location.assign('/demo-app');
}}
>
Reset config and retry
</button>
</div>
</div>
);
}
if (!isAuthenticated || !user) {
return null;
}
return (
<div className={styles.app}>
<div className={styles.card}>
{showDevPanel && <DevPanel />}
<div className={[styles.card, styles.congrats].join(' ')}>
{congratsIcon && <img src={congratsIcon} alt="Congrats" />}
<div className={styles.title}>{t('title')}</div>
<div className={styles.text}>{t('subtitle')}</div>
@ -99,19 +136,36 @@ const Main = () => {
>
{t('sign_out')}
</div>
<div
role="button"
tabIndex={0}
className={styles.button}
onClick={toggleDevPanel}
onKeyDown={({ key }) => {
if (key === 'Enter' || key === ' ') {
toggleDevPanel();
}
}}
>
{showDevPanel ? 'Close' : 'Open'} dev panel
</div>
</div>
</div>
);
};
const App = () => {
const config = getLocalData('config');
return (
<LogtoProvider
config={{
endpoint: window.location.origin,
appId: demoAppApplicationId,
prompt: [Prompt.Login, Prompt.Consent],
scopes: [UserScope.Organizations, UserScope.OrganizationRoles],
// eslint-disable-next-line no-restricted-syntax
prompt: config.prompt ? (config.prompt.split(' ') as Prompt[]) : [],
scopes: config.scope ? config.scope.split(' ') : [],
resources: config.resource ? config.resource.split(' ') : [],
}}
>
<Main />

View file

@ -0,0 +1,114 @@
import { useLogto } from '@logto/react';
import { decodeJwt } from 'jose';
import { useCallback, useState, type FormEventHandler } from 'react';
import * as styles from './App.module.scss';
import { getLocalData, setLocalData } from './utils';
const safeDecodeJwt = (token: string) => {
try {
return decodeJwt(token);
} catch {
return token;
}
};
const DevPanel = () => {
const config = getLocalData('config');
const [showSaved, setShowSaved] = useState(false);
const { getAccessToken, getIdTokenClaims, fetchUserInfo } = useLogto();
const submitConfig: FormEventHandler<HTMLFormElement> = useCallback((event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = Object.fromEntries(formData.entries());
setLocalData('config', data);
setShowSaved(true);
setTimeout(() => {
setShowSaved(false);
}, 500);
}, []);
const requestToken: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = Object.fromEntries(formData.entries());
const token = await getAccessToken(
data.resource ? String(data.resource) : undefined,
data.organizationId ? String(data.organizationId) : undefined
);
console.log(token ? safeDecodeJwt(token) : 'No token');
},
[getAccessToken]
);
return (
<div className={[styles.card, styles.devPanel].join(' ')}>
<form onSubmit={submitConfig}>
<div className={styles.title}>Logto config</div>
<div className={styles.item}>
<div className={styles.text}>Prompt</div>
<input name="prompt" defaultValue={config.prompt} type="text" />
</div>
<div className={styles.item}>
<div className={styles.text}>Scope</div>
<input name="scope" defaultValue={config.scope} type="text" />
</div>
<div className={styles.item}>
<div className={styles.text}>Resource (space delimited)</div>
<input name="resource" defaultValue={config.resource} type="text" />
</div>
<div className={styles.action}>
<div className={styles.text}>Sign out to apply changes.</div>
<button type="submit" className={styles.button}>
{showSaved ? 'Saved' : 'Save'}
</button>
</div>
</form>
<form onSubmit={requestToken}>
<div className={styles.title}>Refresh token grant</div>
<div className={styles.item}>
<div className={styles.text}>Resource</div>
<input name="resource" type="text" />
</div>
<div className={styles.item}>
<div className={styles.text}>Organization ID</div>
<input name="organizationId" type="text" />
</div>
<div className={styles.action}>
<div className={styles.text}>See console for the result.</div>
<button type="submit" className={styles.button}>
Request token
</button>
</div>
</form>
<div>
<div className={styles.title}>User info</div>
<div className={styles.text}>See console for the result.</div>
<p>
<button
className={styles.button}
onClick={async () => {
console.log(await getIdTokenClaims());
}}
>
Get ID token claims
</button>
</p>
<p>
<button
className={styles.button}
onClick={async () => {
console.log(await fetchUserInfo());
}}
>
Fetch user info
</button>
</p>
</div>
</div>
);
};
export default DevPanel;

View file

@ -0,0 +1,70 @@
import { Prompt, UserScope } from '@logto/react';
import { z } from 'zod';
type LocalLogtoConfig = {
prompt?: string;
scope?: string;
resource?: string;
};
const localLogtoConfigGuard = z.object({
prompt: z.string(),
scope: z.string(),
resource: z.string(),
}) satisfies z.ZodType<LocalLogtoConfig>;
type LocalUiConfig = {
showDevPanel?: boolean;
};
const localUiConfigGuard = z.object({
showDevPanel: z.boolean(),
}) satisfies z.ZodType<LocalUiConfig>;
type Key = 'config' | 'ui';
const keyPrefix = 'logto:demo-app:dev:';
type KeyToType = {
config: LocalLogtoConfig;
ui: LocalUiConfig;
};
const keyToGuard: Readonly<{
[K in Key]: z.ZodType<KeyToType[K]>;
}> = Object.freeze({
config: localLogtoConfigGuard,
ui: localUiConfigGuard,
});
const keyToDefault = Object.freeze({
config: {
prompt: [Prompt.Login, Prompt.Consent].join(' '),
scope: [UserScope.Organizations, UserScope.OrganizationRoles].join(' '),
},
ui: {},
} satisfies Record<Key, unknown>);
const safeJsonParse = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
const safeZodParse = (guard: z.ZodType<unknown>, value: unknown) => {
const result = guard.safeParse(value);
return result.success ? result.data : {};
};
export const getLocalData = <K extends Key>(key: K): KeyToType[K] => {
const result = keyToGuard[key].safeParse(
safeJsonParse(localStorage.getItem(`${keyPrefix}${key}`) ?? '')
);
return result.success ? result.data : keyToDefault[key];
};
export const setLocalData = (key: Key, value: unknown) => {
localStorage.setItem(`${keyPrefix}${key}`, JSON.stringify(safeZodParse(keyToGuard[key], value)));
};

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Sie haben sich erfolgreich bei der Live-Vorschau angemeldet!',
subtitle: 'Hier sind Ihre Anmeldeinformationen:',
username: 'Benutzername: ',
user_id: 'Benutzer ID: ',
sign_out: 'Abmeldung von der Live-Vorschau',

View file

@ -1,6 +1,6 @@
const demo_app = {
title: "You've successfully signed in the live preview!",
subtitle: 'Here is your log in information:',
subtitle: 'Here is your user information:',
username: 'Username: ',
user_id: 'User ID: ',
sign_out: 'Sign out the live preview',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: '¡Ha iniciado sesión correctamente en la vista previa en vivo!',
subtitle: 'Aquí está su información de inicio de sesión:',
username: 'Nombre de usuario: ',
user_id: 'ID de usuario: ',
sign_out: 'Cerrar sesión en la vista previa en vivo',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: "Vous vous êtes connecté avec succès à l'aperçu en direct !",
subtitle: 'Voici vos informations de connexion :',
username: "Nom d'utilisateur :",
user_id: "ID de l'utilisateur :",
sign_out: "Se déconnecter de l'aperçu en direct",

View file

@ -1,6 +1,5 @@
const demo_app = {
title: "Hai effettuato l'accesso anteprima live con successo!",
subtitle: 'Ecco le tue informazioni di accesso:',
username: 'Nome utente: ',
user_id: 'ID utente: ',
sign_out: "Esci dall'anteprima live",

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'ライブプレビューへのサインインに成功しました!',
subtitle: 'あなたのログイン情報は以下の通りです:',
username: 'ユーザー名:',
user_id: 'ユーザーID',
sign_out: 'ライブプレビューからサインアウトする',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Live Preview에 성공적으로 로그인했습니다!',
subtitle: '여기 로그인 정보가 있어요:',
username: '사용자 이름: ',
user_id: '사용자 ID: ',
sign_out: 'Live Preview에서 로그아웃',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Pomyślnie zalogowałeś się do podglądu na żywo!',
subtitle: 'Oto twoje informacje logowania:',
username: 'Nazwa użytkownika: ',
user_id: 'ID użytkownika: ',
sign_out: 'Wyloguj się z podglądu na żywo',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: "You've successfully signed in the live preview!",
subtitle: 'Aqui estão suas informações de login:',
username: 'Nome de usuário: ',
user_id: 'ID do usuário: ',
sign_out: 'Sair da Visualização ao Vivo',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Iniciou sessão com sucesso na pré-visualização ao vivo!',
subtitle: 'Aqui estão as suas informações de login:',
username: 'Utilizador: ',
user_id: 'ID de utilizador: ',
sign_out: 'Terminar sessão na visualização ao vivo',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Вы успешно вошли в живой просмотр!',
subtitle: 'Вот ваши данные для входа:',
username: 'Имя пользователя: ',
user_id: 'Идентификатор пользователя: ',
sign_out: 'Выйти из живого просмотра',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: 'Canlı önizlemede başarıyla oturum açtınız!',
subtitle: 'Sisteme giriş bilgileriniz:',
username: 'Kullanıcı Adı: ',
user_id: 'Kullanıcı Kimliği: ',
sign_out: 'Canlı Önizlemeyi Kapat',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: '你已成功登录实时预览!',
subtitle: '以下是本次登录的用户信息:',
username: '用户名:',
user_id: '用户 ID',
sign_out: '退出实时预览',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: '你已成功登錄實時預覽!',
subtitle: '以下是本次登錄的用戶信息:',
username: '用戶名:',
user_id: '用戶 ID',
sign_out: '退出實時預覽',

View file

@ -1,6 +1,5 @@
const demo_app = {
title: '你已成功登錄實時預覽!',
subtitle: '以下是本次登錄的用戶信息:',
username: '用戶名:',
user_id: '用戶 ID',
sign_out: '退出實時預覽',

View file

@ -2876,8 +2876,8 @@ importers:
specifier: workspace:^1.6.1
version: link:../phrases-experience
'@logto/react':
specifier: ^3.0.8
version: 3.0.8(react@18.2.0)
specifier: ^3.0.11
version: 3.0.11(react@18.2.0)
'@logto/schemas':
specifier: workspace:^1.17.0
version: link:../schemas
@ -3471,8 +3471,8 @@ importers:
specifier: workspace:^1.10.0
version: link:../phrases
'@logto/react':
specifier: ^3.0.8
version: 3.0.8(react@18.2.0)
specifier: ^3.0.11
version: 3.0.11(react@18.2.0)
'@logto/schemas':
specifier: workspace:^1.15.0
version: link:../schemas
@ -3515,6 +3515,9 @@ importers:
i18next-browser-languagedetector:
specifier: ^8.0.0
version: 8.0.0
jose:
specifier: ^5.0.0
version: 5.2.4
lint-staged:
specifier: ^15.0.0
version: 15.0.2
@ -5242,12 +5245,15 @@ packages:
resolution: {integrity: sha512-yDWSZMI2Qo/xoYU92tnwSP/gnSvq8+CLK5DqD/4brO42QJa7xjt7eA+HSyuMmSUrKffY2nP3riU81gs+nR8DkA==}
engines: {node: ^18.12.0}
'@logto/browser@2.2.10':
resolution: {integrity: sha512-y6NauaxctqpfApccP6uFVmpg/vG1OhsDVLD4Pdpzbmj3whl63Nb17yxSTQHt4eYNKmSZJ2SzudAnMnVEYD91iQ==}
'@logto/browser@2.2.13':
resolution: {integrity: sha512-7fyenm6f2xSzZc8GHFpAL38rxAXf1hQH/ySSow8QHjgypF0pz9zy9bRSG1oVFgsMrFWYf73dPk7V2ACQ5Tujww==}
'@logto/client@2.6.6':
resolution: {integrity: sha512-QT7jMnzEIWHBNrf9/M8p1OErRBbbNZjoekXGji5aZCyUh975hh8+GEBL21HV71FT3H/5Cq4Gf1GzUbAIW3izMA==}
'@logto/client@2.7.0':
resolution: {integrity: sha512-8mj+757befwQJ4M0h4f3fQwUB1lruDkyLNTyRFOQxFfDMN1QeD70B4nMnz6iTX/Gi09HnIskmj9MHWrbvH6LSQ==}
'@logto/cloud@0.2.5-a7eedce':
resolution: {integrity: sha512-FFjkGjqUgn9PCZnSuCODm2FcjqBm4JfPxfHCiXlOkUjeUhTJLrj7C0gjKzSQ/B6IaWri4EXN/meuqi5z/AMIPg==}
engines: {node: ^20.9.0}
@ -5255,11 +5261,14 @@ packages:
'@logto/js@4.1.1':
resolution: {integrity: sha512-+RgthBvDw30UojirtAjZeHNfOwDQVURmpjcIBYTIf6afx5F5jJq8b1D/eaFbrCFrmXmatkT2iN7X8kYHui86WQ==}
'@logto/js@4.1.3':
resolution: {integrity: sha512-TIYrVSyD0c1mEt3fU9NKbWRTblujs3Ct/DYgNzBLzE/tmSVvwM6Z4JxnwZZ3xyfnL8dC4UfSQRlc2gjSMzKUGw==}
'@logto/node@2.4.7':
resolution: {integrity: sha512-AlANeqY1NIt93EBcRzrTmyAVHXOHpszTJK+qe1ok50rmZlTmX2p7yQvrg0/Ehwf/+4Rla5vooAR+HIFMaOmPpQ==}
'@logto/react@3.0.8':
resolution: {integrity: sha512-p3pV4rX4g8ZwHQ159mxI+pP3Bwome47dNEmP1hI8/10WqdIPXGYTnfYn5c2l4Y2DyslYyK3ur2Sy4i4K6ept9A==}
'@logto/react@3.0.11':
resolution: {integrity: sha512-JyOOf7zZOEg7fTRldfi9SGwwbuk3qJTlIKld+GQ1yGWIXilI2JyyY2XQraZWpHQLW8KtQFgRWQ/fUKV0bideFQ==}
peerDependencies:
react: '>=16.8.0'
@ -14951,9 +14960,9 @@ snapshots:
'@silverhand/essentials': 2.9.1
tiny-cookie: 2.4.1
'@logto/browser@2.2.10':
'@logto/browser@2.2.13':
dependencies:
'@logto/client': 2.6.6
'@logto/client': 2.7.0
'@silverhand/essentials': 2.9.1
js-base64: 3.7.5
@ -14964,6 +14973,13 @@ snapshots:
camelcase-keys: 7.0.2
jose: 5.2.4
'@logto/client@2.7.0':
dependencies:
'@logto/js': 4.1.3
'@silverhand/essentials': 2.9.1
camelcase-keys: 7.0.2
jose: 5.2.4
'@logto/cloud@0.2.5-a7eedce(zod@3.22.4)':
dependencies:
'@silverhand/essentials': 2.9.1
@ -14976,15 +14992,20 @@ snapshots:
'@silverhand/essentials': 2.9.1
camelcase-keys: 7.0.2
'@logto/js@4.1.3':
dependencies:
'@silverhand/essentials': 2.9.1
camelcase-keys: 7.0.2
'@logto/node@2.4.7':
dependencies:
'@logto/client': 2.6.6
'@silverhand/essentials': 2.9.1
js-base64: 3.7.5
'@logto/react@3.0.8(react@18.2.0)':
'@logto/react@3.0.11(react@18.2.0)':
dependencies:
'@logto/browser': 2.2.10
'@logto/browser': 2.2.13
'@silverhand/essentials': 2.9.1
react: 18.2.0