docs: add passage integration guide
No files matched your search
@@ -881,6 +881,7 @@ export const integrations = {
|
||||
{ name: 'Authsignal', url: '/guides/integrations/authsignal' },
|
||||
{ name: 'Clerk', url: '/guides/integrations/clerk' },
|
||||
{ name: 'keyri', url: '/guides/integrations/keyri' },
|
||||
{ name: 'Passage', url: '/guides/integrations/passage' },
|
||||
{ name: 'Stytch', url: '/guides/integrations/stytch' },
|
||||
{ name: 'SuperTokens', url: '/guides/integrations/supertokens' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'passage',
|
||||
title: 'Passage',
|
||||
description:
|
||||
'Build a Next.js application that uses passkey authentication with Passage and Row Level Security (RLS) with Supabase.',
|
||||
}
|
||||
|
||||
This guide steps through building a Next.js application with Passage and Supabase. We will use Passage to authenticate users and manage tokens, while using Supabase for storing data and enforcing authorization logic using Row Level Security policies.
|
||||
|
||||
The full code example for this guide can be found [here](https://github.com/passageidentity/supabase-passage-example).
|
||||
|
||||
[Passage](https://passage.id) is a passwordless authentication platform that makes it simple for developers to add passkey authentication to their apps and websites, providing better security and simpler sign in for your users. They provide simple frontend components that handle all of the complexity of passwordless login for developers in just two lines of codes. Passage also provides session management, user management, and in-depth customization capabilities.
|
||||
|
||||
Next.js is a web application framework built on top of React. We will be using it for this example, as it allows us to write server-side logic within our application. Passage’s [frontend elements](https://docs.passage.id/frontend/passage-element) and [Node.js SDK](https://docs.passage.id/backend/overview/node) are designed to work for Next.js.
|
||||
|
||||
For this guide, you will need a Passage account which can be created [here](https://console.passage.id/register), and a Supabase account which can be created [here](https://app.supabase.com/).
|
||||
|
||||
## 1. Create a Passage application
|
||||
In the [Passage Console](https://console.passage.id), create a new application with the following configuration:
|
||||
|
||||
* Application name: Todo Application
|
||||
* Authentication origin: http://localhost:3000
|
||||
* Redirect URL: /dashboard
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 2. Create and configure a Supabase project
|
||||
|
||||
#### Create a new project
|
||||
In the [Supabase dashboard](https://app.supabase.com/), click `New Project`.
|
||||
Enter a name for your project and create a secure database password.
|
||||
|
||||
#### Create a table schema
|
||||
We are building Todo list application, similar to the [Supabase demo application](https://github.com/supabase/supabase/tree/master/examples/todo-list/nextjs-todo-list) so we will need a table for the todo list items.
|
||||
|
||||
Create a new table in the Table Editor view.
|
||||
|
||||
Set the Name field to `todo`.
|
||||
|
||||
Select Enable Row Level Security (RLS).
|
||||
|
||||
Create the following new columns.
|
||||
* `title` as `text`
|
||||
* `user_id` as `text` with a default value of `auth.user_id()`
|
||||
* `is_complete` as `bool` with a default value of `false`
|
||||
|
||||
Click `Save` to create the table.
|
||||
|
||||

|
||||
|
||||
#### Add initial data to the table
|
||||
From the Table editor, select the `todo` table and click `Insert row`. Fill out the required fields with an example todo item, leaving the `user_id` as NULL and click `Save`.
|
||||
|
||||

|
||||
|
||||
After adding a few todo items, the table editor view will look like this:
|
||||
|
||||

|
||||
|
||||
## 3. Build a Next.js app
|
||||
|
||||
#### Create Next.js app
|
||||
|
||||
Create a new Next.js project on the command line. You can choose your settings through the setup wizard - for this guide we will use JavaScript instead of TypeScript. This example uses the create script in Next v13.2.
|
||||
|
||||
```sh
|
||||
npx create-next-app <name-of-project>
|
||||
cd <name-of-project>/
|
||||
```
|
||||
|
||||
You should be able to use the default settings for the project, but here are the settings used for the example app.
|
||||
|
||||

|
||||
|
||||
#### Configure your ENV
|
||||
|
||||
Create a `.env` file and enter the following values.
|
||||
|
||||
```sh
|
||||
NEXT_PUBLIC_PASSAGE_APP_ID=get-from-passage-settings
|
||||
PASSAGE_API_KEY=get-from-passage-settings
|
||||
NEXT_PUBLIC_SUPABASE_URL=get-from-supabase-dashboard
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=get-from-supabase-dashboard
|
||||
SUPABASE_JWT_SECRET=get-from-supabase-dashboard
|
||||
```
|
||||
|
||||
The Supabase values can be found under `Project->Settings->API Settings`.
|
||||
|
||||

|
||||
|
||||
The Passage values can be found under `General->Settings` and `General->API Keys`.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
> The `PASSAGE_API_KEY` and `SUPABASE_JWT_SECRET` are secret values and should never be shared publicly. They will only be used in the server-side code of the Next.js application.
|
||||
|
||||
Restart your Next.js development server to read in the environment variables.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 4. Add Passage login to your app
|
||||
|
||||
#### Add Passage Element
|
||||
|
||||
Install the `@passageidentity/passage-elements` package.
|
||||
|
||||
```bash
|
||||
npm install @passageidentity/passage-elements
|
||||
```
|
||||
|
||||
Create a new folder called components with a new login file `components/login.js` and add the following content.
|
||||
|
||||
```js
|
||||
// components/login.js
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const PassageLogin = () => {
|
||||
|
||||
useEffect(()=>{
|
||||
require('@passageidentity/passage-elements/passage-auth');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<passage-auth app-id={process.env.NEXT_PUBLIC_PASSAGE_APP_ID}></passage-auth>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PassageLogin
|
||||
```
|
||||
|
||||
Then update `pages/index.js` to include the login component.
|
||||
|
||||
```js
|
||||
// pages/index.js
|
||||
import styles from '@/styles/Home.module.css'
|
||||
import PassageLogin from '@/components/login'
|
||||
|
||||
|
||||
export default function Home(props) {
|
||||
return(
|
||||
<div className={styles.main}>
|
||||
<PassageLogin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
When we have a successful registration the Passage element will request a redirect to `/dashboard` per the redirect URL we set during app creation.
|
||||
|
||||
Create a new file `pages/dashboard.js` for this new route with the following content:
|
||||
|
||||
```js
|
||||
// pages/dashboard.js
|
||||
import styles from '@/styles/Home.module.css'
|
||||
|
||||
export default function Dashboard({isAuthorized, userID, todos}) {
|
||||
return(
|
||||
<div className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<p>
|
||||
You've logged in!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
Now when you visit `http://localhost:3000` in a browser you will have a fully functioning and passwordless login page!
|
||||
|
||||

|
||||
|
||||
Go ahead and go through the registration process. You will be able to register an account with either a passkey or a magic link. Once you've logged in, you will notice that you just get redirected to `/dashboard` page.
|
||||
The login was successful, but we need to build in the functionality to know when a user is authenticated and show them the appropriate view.
|
||||
|
||||
#### Use Passage to verify the JWT
|
||||
|
||||
Now we will need to use a Passage SDK to verify the JWT from Passage.
|
||||
|
||||
Install the Passage Node.js library.
|
||||
|
||||
```bash
|
||||
npm install @passageidentity/passage-node
|
||||
```
|
||||
|
||||
Create a utils folder and a file called `utils/passage.js` with the following content.
|
||||
|
||||
```js
|
||||
// utils/passage.js
|
||||
import Passage from '@passageidentity/passage-node';
|
||||
|
||||
const passage = new Passage({
|
||||
appID: process.env.NEXT_PUBLIC_PASSAGE_APP_ID,
|
||||
apiKey: process.env.PASSAGE_API_KEY,
|
||||
});
|
||||
|
||||
export const getAuthenticatedUserFromSession = async (req, res) => {
|
||||
try {
|
||||
const userID = await passage.authenticateRequest(req);
|
||||
if (userID) {
|
||||
return {isAuthorized: true, userID: userID};
|
||||
}
|
||||
} catch (error) {
|
||||
// authentication failed
|
||||
return {isAuthorized: false, userID: ''};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will be used in the `getServerSideProps()` function to check authentication status for a user. Add this function to `index.js` then update the `Home` function to use the props.
|
||||
|
||||
```js
|
||||
// pages/index.js
|
||||
import styles from '@/styles/Home.module.css'
|
||||
import PassageLogin from '@/components/login'
|
||||
import { getAuthenticatedUserFromSession } from '@/utils/passage'
|
||||
import { useEffect } from 'react'
|
||||
import Router from 'next/router';
|
||||
|
||||
export default function Home({isAuthorized}) {
|
||||
useEffect(()=> {
|
||||
if(isAuthorized){
|
||||
Router.push('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
return(
|
||||
<div className={styles.main}>
|
||||
<PassageLogin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context) => {
|
||||
const loginProps = await getAuthenticatedUserFromSession(context.req, context.res)
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized?? false,
|
||||
userID: loginProps.userID?? ''
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We will also use this logic on the dashboard page to check if a user is authenticated. If not we should redirect them to the login page. We will also add a quick sign out button using Passage while we are at it.
|
||||
|
||||
```js
|
||||
// pages/dashboard.js
|
||||
import styles from '@/styles/Home.module.css'
|
||||
import { useEffect } from 'react';
|
||||
import Router from 'next/router';
|
||||
import { getAuthenticatedUserFromSession } from '@/utils/passage'
|
||||
import { PassageUser } from '@passageidentity/passage-elements/passage-user'
|
||||
|
||||
export default function Dashboard({isAuthorized, userID}) {
|
||||
useEffect(() => {
|
||||
if(!isAuthorized){
|
||||
Router.push('/');
|
||||
}
|
||||
})
|
||||
|
||||
const signOut = async ()=>{
|
||||
new PassageUser().signOut()
|
||||
Router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<h1>
|
||||
Welcome {userID}!{' '}
|
||||
</h1>
|
||||
<br></br>
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context) => {
|
||||
const loginProps = await getAuthenticatedUserFromSession(context.req, context.res)
|
||||
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized?? false,
|
||||
userID: loginProps.userID?? '',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The app can now tell the difference between an authenticated and unauthenticated user. When you log into the application, you will be redirected to the dashboard and see this message.
|
||||
|
||||

|
||||
|
||||
## 5. Integrate Supabase into Next.js app
|
||||
|
||||
Passage and Supabase do not currently allow for custom signing secrets. Therefore, we will need to extract the necessary claims from the Passage JWT and sign a new JWT to send to Supabase.
|
||||
|
||||
Because of the sensitive nature of this functionality, we will handle the authentication and JWT exchange in Next.js’s server-side rendering function `getServerSideProps()`. Imports used in this function will not be bundled client-side. Additionally, the JWT provided by Passage is stored in a cookie which is automatically passed to `getServerSideProps()`.
|
||||
|
||||
#### Sign Passage token for Supabase
|
||||
|
||||
Install the Supabase client SDK and the popular Node package `jsonwebtoken`, which allows us to easily work with JWTs.
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js jsonwebtoken
|
||||
```
|
||||
|
||||
Create a new file called `utils/supabase.js` and add the following content. This function accepts a Passage user ID and then creates and signs a Supabase JWT for that user. This allows Supabase to verify the token and authenticate the user when making Supabase calls.
|
||||
|
||||
```js
|
||||
// utils/supabase.js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const getSupabase = (userId) => {
|
||||
const options = {}
|
||||
|
||||
if (userId) {
|
||||
const payload = {
|
||||
userId,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
}
|
||||
const token = jwt.sign(payload, process.env.SUPABASE_JWT_SECRET)
|
||||
|
||||
options.global = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
options
|
||||
)
|
||||
return supabase
|
||||
}
|
||||
|
||||
export { getSupabase }
|
||||
```
|
||||
|
||||
#### Enable Row Level Security (RLS) in Supabase
|
||||
|
||||
To enable users to view and create their own todo items we need to write a RLS policy. Our policy will check the currently logged in user is to determine whether or not they should have access. Let's create a PostgreSQL function to extract the current user from our new JWT.
|
||||
|
||||
Navigate back to the Supabase dashboard, select `SQL Editor` from the sidebar menu, and click `New query`. This will create a new query called `new sql snippet`, which will allow us to run any SQL against our Postgres database.
|
||||
|
||||
Write the following and click `Run`.
|
||||
|
||||
```sql
|
||||
create or replace function auth.user_id() returns text as $$
|
||||
select nullif(current_setting('request.jwt.claims', true)::json->>'userId', '')::text;
|
||||
$$ language sql stable;
|
||||
```
|
||||
|
||||
You should see the output `Success, no rows returned`. This created a function called `auth.user_id()` which will return the `userId` field of our JWT payload.
|
||||
|
||||
> To learn more about PostgresSQL functions, check out this [deep dive video](https://www.youtube.com/watch?v=MJZCCpCYEqk).
|
||||
|
||||
Now we can create a policy that checks whether the current user is the owner of a todo item. From the `Authentication` sidebar menu in Supabase, click `Policies` then create a new policy.
|
||||
|
||||

|
||||
|
||||
Choose `For full customization create a policy from scratch` and add the following.
|
||||
|
||||

|
||||
|
||||
This policy is calling the function we just created to get the currently logged in user's ID `auth.user_id() `and checking whether this matches the `user_id` column for the current todo. If it does, then it will allow the user to select it, otherwise it will deny access.
|
||||
|
||||
Click `Review` and then `Save policy`.
|
||||
|
||||
> Note: To learn more about RLS and policies, check out this [deep dive video](https://www.youtube.com/watch?v=Ow_Uzedfohk).
|
||||
|
||||
#### Fetch data from Supabase
|
||||
|
||||
Now we can fetch data from Supabase specific to that user. We will update `pages/dashboard.js` to do the following:
|
||||
1. authenticate the user using Passage
|
||||
2. create and sign a JWT for the user with the Supabase secret
|
||||
3. query Supabase to fetch a user’s todo list items
|
||||
|
||||
```js
|
||||
// pages/dashboard.js
|
||||
import styles from '@/styles/Home.module.css'
|
||||
import { useEffect } from 'react';
|
||||
import Router from 'next/router';
|
||||
import { getAuthenticatedUserFromSession } from '@/utils/passage'
|
||||
import { getSupabase } from '../utils/supabase'
|
||||
|
||||
|
||||
export default function Dashboard({isAuthorized, userID, todos}) {
|
||||
useEffect(() => {
|
||||
if(!isAuthorized){
|
||||
Router.push('/');
|
||||
}
|
||||
})
|
||||
|
||||
return(
|
||||
<div className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<h1>
|
||||
Welcome {userID}!{' '}
|
||||
</h1>
|
||||
<br></br>
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
<br></br>
|
||||
<div className={styles.list}>
|
||||
{todos?.length > 0 ? (
|
||||
todos.map((todo) => <li key={todo.id}>{todo.title}</li>)
|
||||
) : (
|
||||
<p>You have completed all todos!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context) => {
|
||||
const loginProps = await getAuthenticatedUserFromSession(context.req, context.res)
|
||||
|
||||
if(loginProps.isAuthorized){
|
||||
const supabase = getSupabase(loginProps.userID)
|
||||
const {data} = await supabase.from('todo').select()
|
||||
|
||||
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized?? false,
|
||||
userID: loginProps.userID?? '',
|
||||
todos: data?? [],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized?? false,
|
||||
userID: loginProps.userID?? ''
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When we reload our application, we are still getting the empty state for todos.
|
||||
|
||||
This is because we enabled Row Level Security, which blocks all requests by default and lets you granularly control access to the data in your database.
|
||||
|
||||
#### Update the UserID data
|
||||
|
||||
The last thing we need to do is update the `user_id` columns for our existing todos. Head back to the Supabase dashboard, and select `Table editor` from the sidebar. You will see that the `user_id` field is NULL for all of our todo items.
|
||||
|
||||

|
||||
|
||||
To get the user ID for our Passage user, go back to the Passage Console and check the `Users` tab.
|
||||
|
||||

|
||||
|
||||
Copy this user ID and update two of the three rows in the Supabase database to match this user ID. When you are done, the database table will look like this.
|
||||
|
||||

|
||||
|
||||
Now when we refresh the application, we will see the todo items for our user!
|
||||
|
||||

|
||||
|
||||
## Bonus: Add todo items
|
||||
|
||||
To build out a bit more functionality in our application, we can now let users add items to their to do list. Create a file `pages/api/addTodo.js` with the following content.
|
||||
|
||||
```js
|
||||
// pages/api/addTodo.js
|
||||
import { getSupabase } from "../../utils/supabase";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const { userID, todo } = req.body;
|
||||
const supabase = getSupabase(userID);
|
||||
const { data, error } = await supabase
|
||||
.from("todo")
|
||||
.insert({ title: todo })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return res.status(400).json(error);
|
||||
res.status(200).json(data);
|
||||
}
|
||||
```
|
||||
Then update `pages/dashboard.js` to include a form for submitting new to do items. The complete file will look like this.
|
||||
|
||||
```js
|
||||
//pages/dashboard.js
|
||||
import styles from "@/styles/Home.module.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import Router from "next/router";
|
||||
import { getAuthenticatedUserFromSession } from "@/utils/passage";
|
||||
import { getSupabase } from "../utils/supabase";
|
||||
import { PassageUser } from "@passageidentity/passage-elements/passage-user";
|
||||
|
||||
export default function Dashboard({ isAuthorized, userID, initialTodos }) {
|
||||
const [todos, setTodos] = useState(initialTodos);
|
||||
useEffect(() => {
|
||||
if (!isAuthorized) {
|
||||
Router.push("/");
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const todo = data.get("todo");
|
||||
const res = await fetch("/api/addTodo", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ todo, userID }),
|
||||
}).then((res) => res.json());
|
||||
setTodos([...todos, res]);
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
new PassageUser().signOut();
|
||||
Router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<h1>Welcome {userID}! </h1>
|
||||
<br></br>
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
<br></br>
|
||||
<div className={styles.list}>
|
||||
{todos?.length > 0 ? (
|
||||
todos.map((todo) => <li key={todo.id}>{todo.title}</li>)
|
||||
) : (
|
||||
<p>You have completed all todos!</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Todo: <input type="text" name="todo" />
|
||||
</label>
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context) => {
|
||||
const loginProps = await getAuthenticatedUserFromSession(
|
||||
context.req,
|
||||
context.res
|
||||
);
|
||||
|
||||
if (loginProps.isAuthorized) {
|
||||
const supabase = getSupabase(loginProps.userID);
|
||||
const { data } = await supabase
|
||||
.from("todo")
|
||||
.select()
|
||||
.is("is_complete", false);
|
||||
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized ?? false,
|
||||
userID: loginProps.userID ?? "",
|
||||
initialTodos: data ?? [],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
isAuthorized: loginProps.isAuthorized ?? false,
|
||||
userID: loginProps.userID ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Finally, we need to add a new RLS policy in Supabase to allow users to insert their own todo items.
|
||||
|
||||

|
||||
|
||||
That's it! Now the website has form for submitting new items for the to do list.
|
||||
|
||||

|
||||
|
||||
## Resources
|
||||
|
||||
* [Passage website](https://passage.id)
|
||||
* [Complete developer documentation](https://docs.passage.id)
|
||||
* [Passage Github](https://github.com/passageidentity), including SDKs and example apps for Next.js
|
||||
* [Developer community](https://discord.com/invite/445QpyEDXh) on Discord
|
||||
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 206 KiB |