In this workshop, you will learn about how to build a decentralized identity profile with Ethereum on Ceramic Networks.
-
To go through this tutorial, you'll need some experience with JavaScript and React.js. Experience with Next.js isn't a requirement, but it's nice to have.
-
Make sure to have Node.js or npm installed on your computer. If you don't, click here.
Also, it'll be very useful to have a basic understanding of blockchain technology and Web3 concepts.
Navigate to the terminal and cd
into any directory of your choice. Then run the following commands:
mkdir decentralized-identity-project
cd decentralized-identity-project
npx create-next-app@latest .
Accept the following options:
Install the react-hot-toast
, @
self.id/react
and @
self.id/web
packages using the code snippet below:
npm install react-hot-toast @self.id/web @self.id/react
Next, start the app using the following command:
npm run dev
You should have something similar to what is shown below: the default boilerplate layout for Next.js 13.
In this section, you will set up Tailwind CSS in a Next.js project. Install tailwindcss
and its peer dependencies via npm, and then run the init command to generate both tailwind.config.js
and postcss.config.js
.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Navigate to the tailwind.config.js
file, and add the paths to your template files with the following code snippet.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Delete all the CSS styles inside globals.css
. Add the @tailwind
directives for each of Tailwind’s layers to your globals.css
file.
@tailwind base;
@tailwind components;
@tailwind utilities;
The Provider
component must be placed at the top of the application tree to use the hooks detailed below. You can use it to supply an initial state as well as a specific configuration for the Self.ID clients and queries.
Update the _app.js
file under the pages folder with the following code snippet:
// Import the Provider component from the "@self.id/react" library.
import { Provider } from "@self.id/react";
// Import the "globals.css" file from the "@/styles" directory.
import "@/styles/globals.css";
// Define the App component as a default export.
export default function App({ Component, pageProps }) {
// Render the Provider component, which provides authentication and authorization functionality to the application.
// Pass a client prop to the Provider component, which configures the Ceramic testnet with the "testnet-clay" value.
// Render the Component with its props inside the Provider component, which allows the application to access the authentication and authorization context.
return (
<Provider client={{ ceramic: "testnet-clay" }}>
<Component {...pageProps} />
</Provider>
);
}
Configure Provider
In the code snippet above, we:
-
Imported a context provider component and global CSS styles and then defined an
App
component that wraps the entire application with the context provider. -
Configured the context provider with a Ceramic testnet client, which allows the application to access authentication and authorization functionality.
-
Finally, the
Component
is rendered with its props inside the context provider, allowing the application to access the authentication and authorization context.
Next, navigate to the index.js
file under the pages
folder and update it with the following code:
// Import the Head component from the "next/head" module.
import Head from "next/head";
// Import the useViewerConnection and useViewerRecord hooks from the "@self.id/react" library.
import { useViewerConnection, useViewerRecord } from "@self.id/react";
// Import the EthereumAuthProvider component from the "@self.id/web" library.
import { EthereumAuthProvider } from "@self.id/web";
// Import the toast and Toaster components from the "react-hot-toast" library.
import { Toaster, toast } from "react-hot-toast";
// Import the useState hook from the "react" module.
import { useEffect, useState } from "react";
export default function Home() {
return (
<>
<Head>
<title>Decentralized Identity Demo</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h3 className="text-2xl font-bold text-gray-900">
Decentralized Identity
</h3>
</div>
</div>
<div className="flex items-center">
<button
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Connect Wallet
</button>
</div>
</div>
</div>
</nav>
<main className="py-10">
<div className="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow sm:rounded-lg px-4 py-5 sm:p-6">
<div className="px-4 py-5 sm:p-6 bg-white">
<form>
<div className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-bold text-gray-700"
>
Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
placeholder="John Doe"
/>
</div>
</div>
<div>
<label
htmlFor="username"
className="block text-sm font-bold text-gray-700"
>
Username
</label>
<div className="mt-1">
<input
type="text"
name="username"
id="username"
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
placeholder="johndoe"
/>
</div>
</div>
<div>
<label
htmlFor="bio"
className="block text-sm font-bold text-gray-700"
>
Bio
</label>
<div className="mt-1">
<textarea
name="bio"
id="bio"
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
rows="3"
placeholder="Tell us a little about yourself..."
></textarea>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Update Profile
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</>
);
}
To start the application, run the following command and navigate to localhost:3000 on your browser; you should have something similar to what is shown below:
In this section, you will implement user authentication to allow users to connect their wallets and interact with the application.
Update the index.js
with the following code:
//...
export default function Home() {
// Calls the useViewerConnection hook to get the connection status, connect and disconnect functions.
const [connection, connect, disconnect] = useViewerConnection();
// Sets up the isWindow state variable to null using useState.
const [isWindow, setIsWindow] = useState(null);
// Creates a new authentication provider using the ethereum account.
async function createAuthProvider() {
// The following assumes there is an injected `window.ethereum` provider
const addresses = await window.ethereum.request({
method: "eth_requestAccounts",
});
return new EthereumAuthProvider(window.ethereum, addresses[0]);
}
// Connects the user's wallet to the website using the connect function and the created authentication provider.
async function connectAccount() {
const authProvider = await createAuthProvider();
connect(authProvider);
}
// Sets the isWindow variable to the window object if it exists.
useEffect(() => {
if (typeof window !== "undefined") {
setIsWindow(window);
}
}, []);
return (
<>
<Head>
{/* ... */}
</Head>
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h3 className="text-2xl font-bold text-gray-900">
Decentralized Identity
</h3>
</div>
</div>
<div className="flex items-center">
{connection.status === "connected" ? (
<button
onClick={() => disconnect()}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Disconnect
</button>
) : isWindow && "ethereum" in window ? (
<button
onClick={() => connectAccount()}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Connect Wallet
</button>
) : (
<p className="text-red-500 text-sm italic mt-2 text-center w-full">
An injected Ethereum provider such as{" "}
<a href="https://metamask.io/">MetaMask</a> is needed to
authenticate.
</p>
)}
</div>
</div>
</div>
</nav>
<main className="py-10">
{/* ... */}
</main>
</div>
</>
);
}
In the code snippet above,
-
The
useViewerConnection
hook is used to set up a state variable for the user's connection status, connect and disconnect. -
isWindow
to set the initial state of the the window to avoid React hydration error -
The
useViewerRecord
hook is used to retrieve the user's basic profile data. -
The
createAuthProvider
function creates anEthereumAuthProvider
object using thewindow.ethereum
provider. -
The
connectAccount
function callscreateAuthProvider
and connects to the user's account usingconnect(authProvider)
. -
The JSX code conditionally renders a button based on the user's connection status and the availability of an
ethereum
provider in thewindow
object. -
If the user is already connected, the button will enable them to disconnect. If the user is not yet connected and an
ethereum
provider is available, the button will enable them to connect. But if the user is not connected and noethereum
provider is available, a message will be displayed to inform the user that an injected Ethereum provider like MetaMask is required to authenticate.
Testing out the authentication functionality, you should have something similar to what is shown below:
In the previous section, you learned how to successfully authenticate users. Next, you will implement functionality to create and update an authenticated user with the following code snippet:
pages/index.js
//...
export default function Home() {
// Sets up the state variables for name, username, and bio.
const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [bio, setBio] = useState("");
// Calls the useViewerRecord hook to get the ceramic record for the user's basic profile.
const record = useViewerRecord("basicProfile");
//...
// Handles the form submission and updates the user's profile on the ceramic database.
const handleSubmit = async (event) => {
event.preventDefault();
if (!name || !username || !bio) {
toast.error("Please fill out all fields");
return;
}
await record.merge({
name,
bio,
username,
});
toast.success("Profile updated");
};
// Render the component's UI
return (
<>
<Head>
{/* ... */}
</Head>
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
{/* ... */}
</nav>
<main className="py-10">
<div className="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow sm:rounded-lg px-4 py-5 sm:p-6">
<div className="px-4 py-5 sm:p-6 bg-white">
<form onSubmit={handleSubmit} noValidate>
<div className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-bold text-gray-700"
>
Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
placeholder="John Doe"
/>
</div>
</div>
<div>
<label
htmlFor="username"
className="block text-sm font-bold text-gray-700"
>
Username
</label>
<div className="mt-1">
<input
type="text"
name="username"
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
placeholder="johndoe"
/>
</div>
</div>
<div>
<label
htmlFor="bio"
className="block text-sm font-bold text-gray-700"
>
Bio
</label>
<div className="mt-1">
<textarea
name="bio"
id="bio"
value={bio}
onChange={(event) => setBio(event.target.value)}
className="block w-full px-4 py-3 rounded-md shadow-sm placeholder-gray-500 focus:ring-gray-500 focus:border-gray-500 border border-gray-300 focus:outline-none "
rows="3"
placeholder="Tell us a little about yourself..."
></textarea>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={!record.isMutable || record.isMutating}
>
{record.isMutating ? "Updating..." : "Update Profile"}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</>
)
}
In the code above,
-
The component uses the useState hook to manage the state of three variables:
name
,bio
, andusername
. -
There's an async function called
handleSubmit
that is responsible for merging the current state of the variables into a record. -
If any of the variables is empty, the
handleSubmit
function returns without updating the record witht he error message "Please fill out all fields".
You are almost there. Let's render the UI containing user record after its been updated.
//...
export default function Home() {
//...
return (
<>
<Head>
{/* ... */}
</Head>
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
{/* ... */}
</nav>
<main className="py-10">
{/* ... */}
{connection.status === "connected" && record && record.content ? (
<div className="max-w-3xl mx-auto mt-8 sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 leading-6">
Your Profile
</h2>
<div className="mt-3 max-w-xl text-sm text-gray-500 grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className="text-gray-700 font-bold" htmlFor="name">
Name:
</label>
<p className="mb-1">{record.content.name}</p>
</div>
<div className="flex flex-col">
<label
className="text-gray-700 font-bold"
htmlFor="username"
>
Username:
</label>
<p className="mb-1">{record.content.username}</p>
</div>
<div className="col-span-2">
<label className="text-gray-700 font-bold" htmlFor="bio">
DID:
</label>
<p className="whitespace-pre-wrap mb-1">
{connection.selfID.id}
</p>
</div>
<div className="col-span-2">
<label className="text-gray-700 font-bold" htmlFor="bio">
Bio:
</label>
<p className="whitespace-pre-wrap mb-1">
{record.content.bio}
</p>
</div>
</div>
</div>
</div>
</div>
) : record &&
!record.content &&
!record.isLoading &&
connection &&
connection.status === "connected" ? (
<div className="max-w-3xl mx-auto mt-8 sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 leading-6">
Profile Information
</h2>
<div className="mt-3 max-w-xl text-sm text-gray-500">
<p>
You don't have a profile yet. Create one by filling
out the form above.
</p>
</div>
</div>
</div>
</div>
) : null}
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{ duration: 4000 }}
/>
</main>
</div>
</>
);
}
In the code snippet above:
- This is a conditional statement that checks if the connection object has a status property that is set to "connected", and if the record object and its content property exist and are truthy. If these conditions are all true, it will render the HTML.
- This HTML code renders a profile page for the user, displaying their
name
,username
,DID
(which may be a unique identifier), andbio
. - Another condition checks if the record object exists and does not have a content property, and if it's not loading, and if the connection object exists and has a status property set to "connected". If all these conditions are true, it will render the HTML.
That's it. You can test out your application similar to what is shown below.
Kindly find the complete code on GitHub repository here.
In this workshop, you learn about building a Decentralized identity profile with Ethereum on Ceramic Networks.