mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
Merge pull request #6046 from logto-io/gao-add-ruby
This commit is contained in:
commit
82e702fbde
13 changed files with 270 additions and 23 deletions
5
.changeset/large-gifts-cross.md
Normal file
5
.changeset/large-gifts-cross.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/console": minor
|
||||
---
|
||||
|
||||
add Ruby app guide
|
|
@ -20,8 +20,7 @@ const data = await Promise.all(
|
|||
return;
|
||||
}
|
||||
|
||||
// Add `.png` later
|
||||
const logo = ['logo.svg'].find((logo) => existsSync(`${directory}/${logo}`));
|
||||
const logo = ['logo.webp', 'logo.svg', 'logo.png'].find((logo) => existsSync(`${directory}/${logo}`));
|
||||
|
||||
const config = existsSync(`${directory}/config.json`)
|
||||
? await import(`./${directory}/config.json`, { assert: { type: 'json' } }).then(
|
||||
|
@ -42,20 +41,31 @@ const metadata = data
|
|||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const camelCase = (value) => value.replaceAll(/-./g, (x) => x[1].toUpperCase());
|
||||
const filename = 'index.ts';
|
||||
const filename = 'index.tsx';
|
||||
|
||||
await fs.writeFile(
|
||||
filename,
|
||||
"// This is a generated file, don't update manually.\n\nimport { lazy } from 'react';\n\nimport { type Guide } from './types';\n"
|
||||
);
|
||||
|
||||
for (const { name } of metadata) {
|
||||
for (const { name, logo } of metadata) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.appendFile(filename, `import ${camelCase(name)} from './${name}/index';\n`);
|
||||
|
||||
if (logo && !logo.endsWith('.svg')) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.appendFile(filename, `import ${camelCase(name)}Logo from './${name}/${logo}';\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.appendFile(filename, '\n');
|
||||
await fs.appendFile(filename, 'const guides: Readonly<Guide[]> = Object.freeze([');
|
||||
await fs.appendFile(filename, 'export const guides: Readonly<Guide[]> = Object.freeze([');
|
||||
|
||||
const getLogo = ({ name, logo }) => {
|
||||
if (!logo) return 'undefined';
|
||||
if (logo.endsWith('.svg')) return `lazy(async () => import('./${name}/${logo}'))`;
|
||||
return `({ className }: { readonly className?: string }) => <img src={${camelCase(name)}Logo} alt="${name}" className={className} />`;
|
||||
};
|
||||
|
||||
for (const { name, logo, order } of metadata) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
@ -65,11 +75,11 @@ for (const { name, logo, order } of metadata) {
|
|||
{
|
||||
order: ${order},
|
||||
id: '${name}',
|
||||
Logo: ${logo ? `lazy(async () => import('./${name}/${logo}'))` : 'undefined'},
|
||||
Logo: ${getLogo({ name, logo })},
|
||||
Component: lazy(async () => import('./${name}/README.mdx')),
|
||||
metadata: ${camelCase(name)},
|
||||
},`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.appendFile(filename, ']);\n\nexport default guides;\n');
|
||||
await fs.appendFile(filename, ']);\n');
|
||||
|
|
|
@ -34,10 +34,19 @@ import webOutline from './web-outline/index';
|
|||
import webPhp from './web-php/index';
|
||||
import webPython from './web-python/index';
|
||||
import webRemix from './web-remix/index';
|
||||
import webRuby from './web-ruby/index';
|
||||
import webRubyLogo from './web-ruby/logo.webp';
|
||||
import webSveltekit from './web-sveltekit/index';
|
||||
import webWordpress from './web-wordpress/index';
|
||||
|
||||
const guides: Readonly<Guide[]> = Object.freeze([
|
||||
export const guides: Readonly<Guide[]> = Object.freeze([
|
||||
{
|
||||
order: 1,
|
||||
id: 'web-next-app-router',
|
||||
Logo: lazy(async () => import('./web-next-app-router/logo.svg')),
|
||||
Component: lazy(async () => import('./web-next-app-router/README.mdx')),
|
||||
metadata: webNextAppRouter,
|
||||
},
|
||||
{
|
||||
order: 1.1,
|
||||
id: 'native-expo',
|
||||
|
@ -59,13 +68,6 @@ const guides: Readonly<Guide[]> = Object.freeze([
|
|||
Component: lazy(async () => import('./spa-react/README.mdx')),
|
||||
metadata: spaReact,
|
||||
},
|
||||
{
|
||||
order: 1.1,
|
||||
id: 'web-next-app-router',
|
||||
Logo: lazy(async () => import('./web-next-app-router/logo.svg')),
|
||||
Component: lazy(async () => import('./web-next-app-router/README.mdx')),
|
||||
metadata: webNextAppRouter,
|
||||
},
|
||||
{
|
||||
order: 1.2,
|
||||
id: 'm2m-general',
|
||||
|
@ -164,6 +166,15 @@ const guides: Readonly<Guide[]> = Object.freeze([
|
|||
Component: lazy(async () => import('./web-php/README.mdx')),
|
||||
metadata: webPhp,
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
id: 'web-ruby',
|
||||
Logo: ({ className }: { readonly className?: string }) => (
|
||||
<img src={webRubyLogo} alt="web-ruby" className={className} />
|
||||
),
|
||||
Component: lazy(async () => import('./web-ruby/README.mdx')),
|
||||
metadata: webRuby,
|
||||
},
|
||||
{
|
||||
order: 2.1,
|
||||
id: 'spa-webflow',
|
||||
|
@ -270,5 +281,3 @@ const guides: Readonly<Guide[]> = Object.freeze([
|
|||
metadata: thirdPartyOidc,
|
||||
},
|
||||
]);
|
||||
|
||||
export default guides;
|
|
@ -40,9 +40,12 @@ export type GuideMetadata = {
|
|||
|
||||
/** The guide instance to build in the console. */
|
||||
export type Guide = {
|
||||
order: number;
|
||||
/** The unique identifier of the guide. */
|
||||
id: string;
|
||||
Logo: LazyExoticComponent<SvgComponent>;
|
||||
Logo:
|
||||
| LazyExoticComponent<SvgComponent>
|
||||
| ((props: { readonly className?: string }) => JSX.Element);
|
||||
Component: LazyExoticComponent<FunctionComponent<MDXProps>>;
|
||||
metadata: Readonly<GuideMetadata>;
|
||||
};
|
||||
|
|
193
packages/console/src/assets/docs/guides/web-ruby/README.mdx
Normal file
193
packages/console/src/assets/docs/guides/web-ruby/README.mdx
Normal file
|
@ -0,0 +1,193 @@
|
|||
import UriInputField from '@/mdx-components/UriInputField';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
import { generateStandardSecret } from '@logto/shared/universal';
|
||||
import Steps from '@/mdx-components/Steps';
|
||||
import Step from '@/mdx-components/Step';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step
|
||||
title="Add Logto SDK as a dependency"
|
||||
subtitle="Use your preferred method of adding gems"
|
||||
>
|
||||
|
||||
```bash
|
||||
bundle add logto
|
||||
```
|
||||
|
||||
Or whatever your preferred method of adding gems is.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Initialize Logto client"
|
||||
subtitle="1 step"
|
||||
>
|
||||
|
||||
<InlineNotification>
|
||||
|
||||
The following demonstration is for Ruby on Rails. However, you can apply the same steps to other Ruby frameworks.
|
||||
|
||||
</InlineNotification>
|
||||
|
||||
In the file where you want to initialize the Logto client (e.g. a base controller or a middleware), add the following code:
|
||||
|
||||
<pre>
|
||||
<code className="language-ruby">
|
||||
{`require "logto/client"
|
||||
|
||||
@client = LogtoClient.new(
|
||||
config: LogtoClient::Config.new(
|
||||
endpoint: "${props.endpoint}",
|
||||
app_id: "${props.app.id}",
|
||||
app_secret: "${props.app.secret}"
|
||||
),
|
||||
navigate: ->(uri) { a_redirect_method(uri) },
|
||||
storage: LogtoClient::SessionStorage.new(the_session_object)
|
||||
)
|
||||
end`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
For instance, in a Rails controller, the code might look like this:
|
||||
|
||||
<pre>
|
||||
<code className="language-ruby">
|
||||
{`# app/controllers/sample_controller.rb
|
||||
require "logto/client"
|
||||
|
||||
class SampleController < ApplicationController
|
||||
before_action :initialize_logto_client
|
||||
|
||||
private
|
||||
|
||||
def initialize_logto_client
|
||||
@client = LogtoClient.new(
|
||||
config: LogtoClient::Config.new(
|
||||
endpoint: "${props.endpoint}",
|
||||
app_id: "${props.app.id}",
|
||||
app_secret: "${props.app.secret}"
|
||||
),
|
||||
# Allow the client to redirect to other hosts (i.e. your Logto tenant)
|
||||
navigate: ->(uri) { redirect_to(uri, allow_other_host: true) },
|
||||
# Controller has access to the session object
|
||||
storage: LogtoClient::SessionStorage.new(session)
|
||||
)
|
||||
end
|
||||
end`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Configure redirect URIs"
|
||||
subtitle="2 URIs"
|
||||
>
|
||||
|
||||
First, let's enter your redirect URI. E.g. `http://localhost:3000/callback`. [Redirect URI](https://www.oauth.com/oauth2-servers/redirect-uris/) is an OAuth 2.0 concept which implies the location should redirect after authentication.
|
||||
|
||||
<UriInputField name="redirectUris" />
|
||||
|
||||
After signing out, it'll be great to redirect user back to your website. For example, add `http://localhost:3000` as the post sign-out redirect URI below.
|
||||
|
||||
<UriInputField name="postLogoutRedirectUris" />
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Handle the callback"
|
||||
subtitle="1 step"
|
||||
>
|
||||
|
||||
<p>
|
||||
Since the redirect URI has been set to <code>{props.redirectUris[0] || 'http://localhost:3000/callback'}</code>, it needs to be handled it in our application. In a Rails controller, you can add the following code:
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
<code className="language-ruby">
|
||||
{`# app/controllers/sample_controller.rb
|
||||
class SampleController < ApplicationController
|
||||
def ${props.redirectUris[0]?.split('/').pop() || 'callback'}
|
||||
@client.handle_sign_in_callback(url: request.original_url)
|
||||
end
|
||||
end`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
And configure the route in `config/routes.rb`:
|
||||
|
||||
<pre>
|
||||
<code className="language-ruby">
|
||||
{`Rails.application.routes.draw do
|
||||
get "${new URL(props.redirectUris[0] || 'http://localhost:3000/callback').pathname}", to: "sample#${props.redirectUris[0]?.split('/').pop() || 'callback'}"
|
||||
end`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
title="Invoke sign-in and sign-out"
|
||||
>
|
||||
|
||||
There are various ways to invoke sign-in and sign-out in your application. For example, you can implement two routes in your Rails application:
|
||||
|
||||
<pre>
|
||||
<code className="language-ruby">
|
||||
{`# app/controllers/sample_controller.rb
|
||||
class SampleController < ApplicationController
|
||||
def sign_in
|
||||
@client.sign_in(redirect_uri: request.base_url + "${new URL(props.redirectUris[0] || 'http://localhost:3000/callback').pathname}")
|
||||
end
|
||||
|
||||
def sign_out
|
||||
@client.sign_out(post_logout_redirect_uri: request.base_url)
|
||||
end
|
||||
|
||||
# ...
|
||||
end`}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
```ruby
|
||||
# config/routes.rb
|
||||
Rails.application.routes.draw do
|
||||
get "/sign_in", to: "sample#sign_in"
|
||||
get "/sign_out", to: "sample#sign_out"
|
||||
|
||||
# ...
|
||||
end
|
||||
```
|
||||
|
||||
Then you can create buttons or links in your views to trigger these actions. For example:
|
||||
|
||||
```erb
|
||||
<!-- app/views/sample/index.html.erb -->
|
||||
<% if @client.is_authenticated? %>
|
||||
<a href="<%= sign_out_path %>">Sign out</a>
|
||||
<% else %>
|
||||
<a href="<%= sign_in_path %>">Sign in</a>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Display user information">
|
||||
|
||||
To display the user's information, you can use the `@client.id_token_claims` method. For example, in a view:
|
||||
|
||||
```erb
|
||||
<!-- app/views/sample/index.html.erb -->
|
||||
<% if @client.is_authenticated? %>
|
||||
<p>Welcome, <%= @client.id_token_claims["name"] %></p>
|
||||
<% else %>
|
||||
<p>Please sign in</p>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
Please refer to the `#id_token_claims` method in the [gemdocs](https://gemdocs.org/gems/logto/latest) for more information.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"order": 2
|
||||
}
|
12
packages/console/src/assets/docs/guides/web-ruby/index.ts
Normal file
12
packages/console/src/assets/docs/guides/web-ruby/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
import { type GuideMetadata } from '../types';
|
||||
|
||||
const metadata: Readonly<GuideMetadata> = Object.freeze({
|
||||
name: 'Ruby',
|
||||
description:
|
||||
'Ruby is a dynamic, open-source programming language with a focus on simplicity and productivity.',
|
||||
target: ApplicationType.Traditional,
|
||||
});
|
||||
|
||||
export default metadata;
|
BIN
packages/console/src/assets/docs/guides/web-ruby/logo.webp
Normal file
BIN
packages/console/src/assets/docs/guides/web-ruby/logo.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
10
packages/console/src/assets/index.d.ts
vendored
10
packages/console/src/assets/index.d.ts
vendored
|
@ -4,3 +4,13 @@ declare module '*.svg' {
|
|||
const value: SvgComponent;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { guides } from '@/assets/docs/guides';
|
||||
import { type Guide } from '@/assets/docs/guides/types';
|
||||
import {
|
||||
thirdPartyAppCategory,
|
||||
|
|
|
@ -3,7 +3,7 @@ import { MDXProvider } from '@mdx-js/react';
|
|||
import classNames from 'classnames';
|
||||
import { type LazyExoticComponent, Suspense, createContext, useContext } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { guides } from '@/assets/docs/guides';
|
||||
import { type GuideMetadata } from '@/assets/docs/guides/types';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
|
@ -17,7 +17,9 @@ import * as styles from './index.module.scss';
|
|||
|
||||
export type GuideContextType = {
|
||||
metadata: Readonly<GuideMetadata>;
|
||||
Logo?: LazyExoticComponent<SvgComponent>;
|
||||
Logo?:
|
||||
| LazyExoticComponent<SvgComponent>
|
||||
| ((props: { readonly className?: string }) => JSX.Element);
|
||||
isCompact: boolean;
|
||||
app?: ApplicationResponse;
|
||||
endpoint?: string;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { type Resource } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { guides } from '@/assets/docs/guides';
|
||||
import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
|
|
|
@ -2,7 +2,7 @@ import { type ApplicationResponse } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { guides } from '@/assets/docs/guides';
|
||||
import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
|
|
Loading…
Add table
Reference in a new issue