How to Build a Production-Ready Todo App in One Next.js Project With ZenStack
JS
Posted on November 10, 2022
Notice: ZenStack has recently released its 1.0 version, and this post was written based on the 0.5 version. Although the basic concept and logic has been the same, some of the code in this post is not applicable in the 1.0 version anymore. I suggest you check out the new tutorial I wrote in:
How to Build a Collaborative SaaS Product Using Next.js and ZenStack's Access Control Policy
JS for ZenStack ・ Feb 5 '23
Next.js did a great job in "bringing the power of full-stack to the front end"as their slogan on the website. However, you(or your collaborator) probably still need to spend significant energy designing and building up your app's server-side part. Things that make you stressed include:
- What kind of API to use? RESTful or GraphQL?
- How to model your data and map the model to both source code and database (ORM)?
- How to implement CRUD operations? Manually construct it or use a generator?
- How to evolve your data model?
- How to authenticate users and authorize their requests?
ZenStack aims to simplify and resolve these tasks from a front-end perspective, so we can move one step further to "bringing the power of full-stack to the front end."
This Tutorial will show you how to create a simple collaborative Todo web app below with the ZenStack library using Next.js and PostgreSQL database step by step.
The app would have below features:
- Register/Sign-in with the email and password.
- Create/Delete a Todo list, either public or private. Public means others could see it.
- Create/Delete/Complete a Todo under a Todo list.
Here is an example of final result:
https://zenstack-nextjs-todo-demo.vercel.app (source)
Join the Conversation
If you have questions about anything related to this tutorial, you're welcome to ask our community on Discord.
It is a long article divided into four chapters. I will make sure you have something fun to play during the quarter break😉:
- Set Up The Project
- User Register Sign-in
- Todo List
- Todo
Let's move on!
1. Set Up The Project
-
Create a template application using the below command:
npx create-next-app todo --use-npm -e https://github.com/zenstackhq/nextjs-auth-postgres-template
Install ZenStack VS code extension. So When you write your ZModel, you can get the syntax highlighting, linting, code completion, formatting, and jump-to-definition in VScode same as writing Typescript:
Let me show you what the ZModel is.
2. User Register Sign-in
ZModel
ZModel is the core part of the ZenStack library. You can think of it as the "Class" in any Object-oriented programming(OOP), including Typescript we are using now, with the main important characteristic: the instance of it will be persisted in the database.
If you are familiar with Object-Relational Mapping(ORM), you might wonder if this is the front-end version of ORM. Yes, but it's only part of it. Besides the data structure usually defined by ORM, ZModel also contains data access policies just like the logic of the Class.
Therefore, the first and foremost job of using ZenStack is to define the Model appropriately. After that, you can 99% focus on front-end development like you used to do. So let's start with the first Model.
All the models should be defined in the schema file zenstack/schema.zmodel
under the root of your project. It should have been generated by the template, open it you can find the below snippet at the top:
datasource db {
provider = 'postgresql'
url = env('DATABASE_URL')
}
The datasource at the top determines how ZenStack will connect the database. We will use Postgres as our database. So what you need to do is to add your database connection URL in the .env file as below:
DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@[YOUR-URL]/postgres"
If you don't have a Postgres database, the simple way to get one is to get a docker instance or a free one from Supabase.
Note: if you want to play around in your local machine without getting a Postgres instance, you can also use SQLite, a file-based database. You can change the datasource as below:
datasource db {
provider="sqlite"
url="file:./dev.db"
}
User ZModel
The rest part of the schema file is the definition of the User ZModel:
/*
* User model
*/
model User {
id String @id @default(uuid())
email String @unique
emailVerified DateTime?
// @password indicates the field is a password and its
// value should be hashed (with bcrypt) before storing
// @omit indicates the field should not be returned on read
password String @password @omit
name String?
// everybody can signup
@@allow('create', true)
// can only be updated and deleted by self
@@allow('read,update,delete', auth() == this)
}
The Model consists of two parts:
- Data structure. It is defined by the individual field. Each field should have a name, type, and optional attributes. The name and type have no different from any other language. The optional attribute adds a constraint to the field. Let's go through them one by one:
-
@id
. It is used to identify an individual record uniquely. Every Model must have an ID. Internally It is mapped as the primary key of the table in the database. -
@default(cuid())
. It is used to give a default value for a field so that you don’t have to give the value for it when creating it. Thecuid()
is to generate a globally unique identifier that has a better lookup performance thanuuid()
. It is usually used together with@id
to generate business independent id. -
@unique
. It defines a unique constraint for the email field. If you are trying to create an instance with an email that has existed, it will throw an error. -
@password
. It indicates the field is a password, and its value should be hashed (with bcrypt) before storing. -
@omit
. It indicates the field should not be returned on read, which means it should only be accessed from the backend, like secret, password, etc.
-
This part is what an ORM provides. Internally, it would be converted into the Prisma schema. Now it is fully compatible with Prisma schema. So to get the complete list of what you can use, please take a reference with the Prisma schema document.
- Access policy. As mentioned above, ZenStack provides a data access policy over the traditional ORM. Let's take a look at the two policies used here:
-
@@allow('create', true)
. It means you can always create a new user, no matter your identity. We need to specify it explicitly because, by default, all the operation is forbidden from the front end. You need to open it explicitly. -
@@allow('read,update,delete', auth() == this)
. It means only the current user instance could be read/written by himself. ZModel allows you to reference the current login user viaauth()
function in access policy expressions. So when the front-end sends the query to the backend through the API provided by ZenStack library, it will first go through the guard code generated by ZenStack in the backend to ensure it would only access the data comply with the policy. Therefore front-end code could safely call the API to implement the business logic without worrying about access control.
-
You can find the complete ZModel language definition here.
Authentication
Almost every modern application has authentication now, which the access policy is based on as the auth()
function you have seen. To simplify the task, ZenStack has integrated with the open source authentication library NextAuth, which we will use in this demo.
NextAuth
If you are familiar with the NextAuth, you can skip this part.
NextAuth has vast coverage of providers ranging from Google, Github, Facebook, Apple, Slack, Twitter, etc. We will use the basic email/password login to avoid external dependency.
Below I've included a description of how to use NextAuth in the Next project. You can get more detailed instructions from the official document of NextAuth.
-
Create a file called
[...nextauth].js
inpages/api/auth
This contains the dynamic route handler for NextAuth.js which will also contain all of your global NextAuth.js configurations.
export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, providers: [ CredentialsProvider({ credentials: { email: { label: "Email Address", type: "email", placeholder: "Your email address", }, password: { label: "Password", type: "password", placeholder: "Your password", }, }, }), ], callbacks: { async session({ session, token, user }) { //Send properties to the client, like an access_token from a provider. session.accessToken = token.accessToken return session } } }; export default NextAuth(authOptions);
The CredentialsProvider is used for the email/password login and uses JWT token to store the session information in the client.
session()
callback function is used to add additional information to the client when callinguseSession()
below. -
Get session data from the front-end
NextAuth provides a
useSession()
React Hook to check if someone is signed in like below:
const { data: session } = useSession() if (session) { return ( <> Signed in as {session.user.email} <br /> <button onClick={() => signOut()}>Sign out</button> </> ) } return ( <> Not signed in <br /> <button onClick={() => signIn()}>Sign in</button> </> )
To be able to use it, first, you'll need to expose the session context,
<SessionProvider/>
, at the top level of your application:
import { SessionProvider } from "next-auth/react" export default function App({ Component, pageProps: { session, ...pageProps }, }) { return ( <SessionProvider session={session}> <Component {...pageProps} /> </SessionProvider> ) }
-
Get session data from the back-end
You can use the
unstable_getServerSession()
method to retrieve the session data like the below:
import { unstable_getServerSession } from "next-auth/next" import { authOptions } from "./auth/[...nextauth]" export default async (req, res) => { const session = await unstable_getServerSession(req, res, authOptions) if (session) { res.send({ content: "This is protected content. You can access this content because you are signed in.", }) } else { res.send({ error: "You must be signed in to view the protected content on this page.", }) } }
-
Persist user data in the database
NextAuth support creating an Adapter to connect your application to whatever database or backend system. ZenStack has implemented its own adapter, you will see how to use it below.
ZenStack Adapter
Let's see how to hook up with the ZenStack Adapter in our demo.
-
The first thing required is to have a user entity defined to include specific fields. The user ZModel defined above meets the requirement, so there is nothing you need to change. The Adapter is hooked in
pages/api/auth/**[...nextauth].js
**
import NextAuth, { NextAuthOptions, User } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { authorize, NextAuthAdapter as Adapter } from "@zenstackhq/runtime/auth"; import service from "@zenstackhq/runtime"; export const authOptions: NextAuthOptions = { // use the ZenStack next-auth adapter for user identity persistence adapter: Adapter(service), session: { strategy: "jwt", }, providers: [ CredentialsProvider({ credentials: { email: { label: "Email Address", type: "email", placeholder: "Your email address", }, password: { label: "Password", type: "password", placeholder: "Your password", }, }, // use the "authorize" helper generated by ZenStack to authenticate user login authorize: authorize(service), }), ], callbacks: { async session({ session, token }) { // unbox the user entity from session and get userId from JWT token return { ...session, user: { ...session.user, id: token.sub!, }, }; }, }, }; export default NextAuth(authOptions);using
authorize()
function generated by ZenStack would actually check the email/password provided by the client to see whether sign-in is allowed when callingsignIn()
provided by NextAuth. -
The value returned by
auth()
is provided via thegetServerUser
hook function when mounting ZenStack APIs inpages/api/zenstack/[…path].ts
:
import { NextApiRequest, NextApiResponse } from "next"; import { type RequestHandlerOptions, requestHandler } from "@zenstackhq/runtime/server"; import { authOptions } from "../auth/[...nextauth]"; import { unstable_getServerSession } from "next-auth"; import service from "@zenstackhq/runtime"; const options: RequestHandlerOptions = { async getServerUser(req: NextApiRequest, res: NextApiResponse) { const session = await unstable_getServerSession(req, res, authOptions); return session?.user; }, }; export default requestHandler(service, options);
-
import { SessionProvider } from "next-auth/react"; import type { AppProps } from "next/app"; import type { Session } from "next-auth"; function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps<{ session: Session }>) { return ( <SessionProvider session={session}> <div className="flex-grow h-100"> <Component {...pageProps} /> </div> </SessionProvider> ); } export default MyApp;
-
Use
useSession()
to see whether the user is successfully checked in.
import React from "react"; import { signIn, signOut, useSession } from "next-auth/react"; export default function Home() { const { status, data } = useSession(); if (status === "loading") { return <p>Loading...</p>; } else if (status === "unauthenticated") { signIn(); return <></>; } else { const authUser = data?.user; return ( <> {authUser && ( <div className="mt-8 text-center flex flex-col items-center w-full"> <h1 className="text-2xl text-gray-800">Hello World!</h1> <button onClick={() => signOut()}>logout</button> </div> )} </> ); } }
Sign up
With sign-in taken care of by the Adapter, we need to implement the signup function, which means we need to be able to create a user entity.
As mentioned above, ZenStack would generate React Hook for each ZModel type. So for User, the userUser()
hook is generated in @zenstackhq/runtime/hooks
export declare function useUser(): {
create: <T extends P.UserCreateArgs>(args: P.UserCreateArgs) => Promise<P.CheckSelect<T, User, P.UserGetPayload<T, keyof T>>>;
find: <T_1 extends P.UserFindManyArgs>(args?: P.SelectSubset<T_1, P.UserFindManyArgs> | undefined) => SWRResponse<P.CheckSelect<T_1, User[], P.UserGetPayload<T_1, keyof T_1>[]>, any>;
get: <T_2 extends P.Subset<P.UserFindFirstArgs, "select" | "include">>(id: String, args?: P.SelectSubset<T_2, P.Subset<P.UserFindFirstArgs, "select" | "include">> | undefined) => SWRResponse<P.CheckSelect<T_2, User, P.UserGetPayload<T_2, keyof T_2>>, any>;
update: <T_3 extends Omit<P.UserUpdateArgs, "where">>(id: String, args: Omit<P.UserUpdateArgs, 'where'>) => Promise<P.CheckSelect<T_3, User, P.UserGetPayload<T_3, keyof T_3>>>;
del: <T_4 extends Omit<P.UserDeleteArgs, "where">>(id: String, args?: Omit<P.UserDeleteArgs, 'where'>) => Promise<P.CheckSelect<T_4, User, P.UserGetPayload<T_4, keyof T_4>>>;
};
You can call these CRUD functions to access the data without explicitly writing HTTP requests.
So let's create a signup page in page/signup.tsx
import React, { useState } from "react";
import Router from "next/router";
import { useUser } from "@zenstackhq/runtime/hooks";
import { signIn } from "next-auth/react";
const SignUp: React.FC = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { create: signup } = useUser();
const submitData = async (e: React.SyntheticEvent) => {
e.preventDefault();
try {
await signup({
data: {
email,
name,
password,
},
});
const signInResult = await signIn("credentials", {
redirect: false,
email,
password,
});
if (signInResult?.ok) {
await Router.push("/");
} else {
console.error("Signin failed:", signInResult?.error);
}
} catch (error) {
console.error(error);
alert("This email has been registered");
}
};
return (
<>
<div className="page">
<form onSubmit={submitData}>
<h1>Signup user</h1>
<input autoFocus onChange={(e) => setName(e.target.value)} placeholder="Name" type="text" value={name} />
<input onChange={(e) => setEmail(e.target.value)} placeholder="Email address" type="text" value={email} />
<input
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
type="password"
value={password}
/>
<input disabled={!name || !email || !password} type="submit" value="Signup" />
<a className="back" href="#" onClick={() => Router.push("/")}>
or Cancel
</a>
</form>
<p>
Already have an account?{" "}
<a
className="signin"
onClick={() =>
signIn(undefined, {
callbackUrl: "/",
})
}
>
Signin now
</a>
.
</p>
</div>
<style jsx>{`
.page {
background: white;
padding: 3rem;
display: flex;
flex-direction: column;
justify-content: center;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.5rem;
margin: 0.5rem 0;
border-radius: 0.25rem;
border: 0.125rem solid rgba(0, 0, 0, 0.2);
}
input[type="submit"] {
background: #ececec;
border: 0;
padding: 1rem 2rem;
cursor: pointer;
}
.signin {
cursor: pointer;
text-decoration: underline;
}
.back {
margin-left: 1rem;
}
`}</style>
</>
);
};
export default SignUp;
The submitData
function shows that signup is actually simply calling the create
function of userUser()
hook provided by ZenStack. Since@password
attribute on the password
property will hash the password in the back end, so we could only pass the plain text here.
If the signup succeeds, it will automatically call the signIn
of NextAuth to get the user signed in and then go to the home page.
Generate From Schema
Once you have the schema ready, there are two things you need to do before running the app.
Firstly, we need to generate all the code stubs from the ZModel schema by running:
npm run generate
You should see the output below :
✔️ Prisma schema and query guard generated
✔️ ZenStack service generated
✔️ React hooks generated
✔️ Next-auth adapter generated
✔️ Typescript source files transpiled
👻 All generators completed successfully!
- The Prisma schema named 'schema.prisma' is generated beside your ZModel file.
- The React Hook to access User is generated under @zenstackhq/runtime/hooks.
- Next-auth adapter is generated @zenstackhq/runtime/auth
- Typescript type definition of User is generated under @zenstackhq/runtime/types
Secondly, We need to sync the database with the schema. If you haven't added DATABASE_URL
in the .env, you can add it now. Then run:
npm run db:push
You should see the output below:
🚀 Your database is now in sync with your Prisma schema. Done in 2.56s
Check your database. There should be a User table generated.
Run the Code
The template project has all it needs to run for the complete user registration and sign-in. So now you can run it as a standard Next.js app by:
npm run build & npm run dev
Then visit http://localhost:3000/singup, you should be able to see the signup form:
After signup with your account information, you will see the familiar message:
Now run the below command:
npm run db:browse
It will open a page in http://localhost:5555/ and show you the data stored in the database. You should be able to see your user record:
Query User Data
We have seen how to create the user entity in the signup chapter above. Next, let's see how to query it.
Similar with create
, it uses the get
hook of useUser
. So you need to pass the user id getting from the useSession
. So Let’s open index.tsx
and change it to below:
import React from "react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useUser } from "@zenstackhq/runtime/hooks";
export default function Home() {
const { status, data } = useSession();
const { get } = useUser();
const { data: user } = get(data?.user.id!);
if (status === "loading") {
return <p>Loading...</p>;
} else if (status === "unauthenticated") {
signIn();
return <></>;
} else {
return (
<>
{user && (
<div className="mt-8 text-center flex flex-col items-center w-full">
<h1 className="text-2xl text-gray-800">Welcome {user.name || user.email}!</h1>
<button onClick={() => signOut()}>logout</button>
</div>
)}
</>
);
}
}
Make sure the server is still running, and revisit http://localhost:3000. You should see your user name displayed on the screen:
If you open the Network in the developer tool panel, you can find the actual HTTP request to get data with the URL looks like below:
http://localhost:3000/api/zenstack/data/User/cl9tqaibt0000t2sskmmw7g3r
cl9tqaibt0000t2sskmmw7g3r
is the id of the user ZModel
Polishing Layout
So far, we have used minimal code to illustrate how the ZenStack library works for you. Before we move on, let's polish our page styling to make it look natural.
We choose to daisyUI component with TailWindCSS utility. We will create a Navigation bar for it. As it's pure front-end Layout work, we will not dive into the detail in this tutorial.
The page looks like this now:
We also replace the default login page with a custom one implemented using Chakra UI components. Again, you can look at how to do that from the official NextAuth document.
Moreover, we combined it with the signup page together, so it looks like this now:
You can check out the finished code under:
https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/signin
3. Todo List
After signing in, the user would first create a Todo list. So let's start to implement a Todo list. Like what we did for the user, it should always start with defining the ZModel. There are two parts of a ZModel: data structure and access policy.
Data Structure
To be simple, let's use List as the name for this Model. So let's consider what data we need for a Todo list entity.
-
A unique id
id String @id @default(uuid())
-
Create/Update time
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
We can use
now()
to let the library generate it automatically when creating so that we don’t have to specify the value ourselves. The@updateAt
will automatically set the field value when this entity is updated. -
A title
title String
-
A private flag. If you remember our Todo list could be either public or private
Boolean @default(false)
-
Owner. To whom this Todo list belongs.
This comes to an essential concept Relations. A relation is a connection between two models in the ZModel schema. In our case, the relation between User and Todo list is that a Todo list must belong to a User; On the other hand, a User could have many Todo lists. This is called One-to-Many relation. The other two One-to-One and Many-to-Many relations will not be covered in this tutorial.
Relation is the predefined constraint between the Model to make, which is usually implemented at the database level. So data consistency would be guaranteed, meaning you can't write the inconsistent data like a non-existing user to be the owner of a Todo list.
Since the relation is between two models, defining it also involves two models.
Todo list ZModel
ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
- ownerId. The value of this field equals to the id of the User entity it belongs to. This is what actually persisted in the database. This field is referenced by the
@relation
attribute. - owner. This is the object reference to the User entity. It would not be persisted in the database. The value would be set by the ZenStack library through the
fields
andreferences
parameters of@relation
attribute.-
fields
tells which fields in the current model store the unique id of the referenced entity. In our case, it is the ownerId above. -
references
tells which fields are the unique id in the referenced Model. In our case, it is the id of the User ZModel. -
onDelete
tells what we should do about this record if the referenced record is deleted.Cascade
means if a user is deleted, then all the Todo list belonging to him would be deleted automatically too.
-
User ZModel
This part is simple. We only need to add the reference to all the Todo Lists belonging to this user. So please add the blew field in the User Model:
model User { ... lists List[] ... }
The benefit of this reversed reference is that when you query the user, you can also get all the Todo lists belonging to this user.
Internally the relation is handled by Prisma too, so if you want to know more about it, please check out the Prisma document.
- ownerId. The value of this field equals to the id of the User entity it belongs to. This is what actually persisted in the database. This field is referenced by the
The complete Todo list Model data structure would be:
model List {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
private Boolean @default(false)
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
}
Access Policy
ToDo List
The access policy depends on the functionality from the business side. So let's go through it one by one:
Firstly, two standard rules probably apply to most cases in web applications:
- You can't access it without login
// require login
@@deny('all', auth() == null)
- The owner has the full access. Remember we have the owner reference field, so we can use it to compare with the User entity returned by
auth()
. Internally it will check the equality using id.
// owner has full access
@@allow('all', auth() == owner)
Then there is a related business functionality: if the Todo list is public, it could also be seen by others without modification.
// can be read by anyone if it is public
@@allow('read', !private)
The logic of permitting/rejecting an operation is as follows:
- By default, all operations are rejected if there isn't any @@allow rule in a model
- The operation is rejected if any of the conditions in @@deny rules evaluate to
true
- Otherwise, the operation is permitted if any of the conditions in @@allow rules evaluate to
true
- Otherwise, the operation is rejected
User
If you can see other's Todo list, you would also need to know to whom it belongs. However, the current policy of user Model only allow read by self, so we need to change it:
// everybody can signup
@@allow('create', true)
// can be read by other users
@@allow('read', auth() != null)
// can only be updated and deleted by self
@@allow('update,delete', auth() == this)
Generate From Schema
This is the same step as what we did for the User registration steps by running the below two commands:
// generate code stub
npm run generate
// sync with database
npm run db:push
Write Front-End Code
With the schema ready, let's focus on the front-end implementation. Firstly, Let's add two more dependencies we will use:
npm i moment
npm i @heroicons/react
Todo list Component
Then let's create the Todo list UI component in components/TodoList.tsx
with the below props:
import { List, User } from "@zenstackhq/runtime/types";
type Props = {
value: List & { owner: User };
};
As this is a Todo list, it needs a Todo list entity to render. ZenStack will generate the corresponding typescript type definition for every Model under @zenstackhq/runtime/types
. So we can directly use it here.
One thing to note is that although the ZenStack library can set the reference type in the query result, the type definition doesn't have that field. So you need to specify it to pass the type checking explicitly.
Next Let’s implement the delete function:
import { useList } from "@zenstackhq/runtime/hooks";
export default function TodoList({ value, deleted }: Props) {
const router = useRouter();
const { del } = useList();
const deleteList = async () => {
if (confirm("Are you sure to delete this list?")) {
try {
await del(value.id);
} catch (error: any) {
if (error.status == 403) {
alert("You are now allowed to do so");
}
}
};
}
To execute the delete operation, all you need to do is call del
hooks provided by ZenStack library and pass the id of the list. Since the hooks is guarded by the access policy, you should always handle the exception when the policy check fails.
Since the Todo list entity has the createdAt/updatedAt field, we would like to show the date on the list. So, let's create a TimeInfo component for it:
import moment from "moment";
type Props = {
value: { createdAt: Date; updatedAt: Date };
};
export default function TimeInfo({ value }: Props) {
return (
<p className="text-sm text-gray-500">
{value.createdAt === value.updatedAt
? `Created ${moment(value.createdAt).fromNow()}`
: `Updated ${moment(value.updatedAt).fromNow()}`}
</p>
);
}
Also, we use picsum.photos to show image covers. To use it, add the below in the next.config.js:
images: {
domains: ["picsum.photos"],
},
The complete code for Todo list component is below:
import Image from "next/image";
import { List, User } from "@zenstackhq/runtime/types";
import { customAlphabet } from "nanoid";
import { LockClosedIcon, TrashIcon } from "@heroicons/react/24/outline";
import Avatar from "./Avatar";
import Link from "next/link";
import { useRouter } from "next/router";
import { useList } from "@zenstackhq/runtime/hooks";
import TimeInfo from "./TimeInfo";
type Props = {
value: List & { owner: User };
deleted?: (value: List) => void;
};
export default function TodoList({ value, deleted }: Props) {
const router = useRouter();
const { del } = useList();
const deleteList = async () => {
if (confirm("Are you sure to delete this list?")) {
try {
await del(value.id);
} catch (error: any) {
if (error.status == 403) {
alert("You are not allowed to do so");
}
}
if (deleted) {
deleted(value);
}
}
};
return (
<div className="card w-80 bg-base-100 shadow-xl cursor-pointer hover:bg-gray-50">
<a>
<figure>
<Image
src={`https://picsum.photos/300/200?r=${value.id}`}
width={320}
height={200}
alt="Cover"
/>
</figure>
</a>
<div className="card-body">
<Link href={`${router.asPath}${value.id}`}>
<a>
<h2 className="card-title line-clamp-1">{value.title || "Missing Title"}</h2>
</a>
</Link>
<div className="card-actions flex w-full justify-between">
<div>
<TimeInfo value={value} />
</div>
<div className="flex space-x-2">
<Avatar user={value.owner} size={18} />
{value.private && (
<div className="tooltip" data-tip="Private">
<LockClosedIcon className="w-4 h-4 text-gray-500" />
</div>
)}
<TrashIcon
className="w-4 h-4 text-gray-500 cursor-pointer"
onClick={() => {
deleteList();
}}
/>
</div>
</div>
</div>
</div>
);
}
Create Todo list
You can use the create
hooks to create a Todo list. Since both createdAt/updated will be handled automatically, we don't need to specify it
const { create } = useList();
await create({
data: {
title,
ownerId: user!.id,
private: _private,
},
});
Get all the Todo list
You can use the find
hooks to retrieve all the Todo list the current user could see:
const { find } = useList();
const { data: lists } = find({
include: {
owner: true,
},
orderBy: {
updatedAt: "desc",
},
});
By default, the related entities wouldn’t be retrieved for performance issues. If you want it in the result, you need to specify it in the include
property.
Let's replace the index.tsx
file with the below content to include the two features above:
import { UserContext } from "@lib/context";
import { ChangeEvent, FormEvent, useContext, useState } from "react";
import { useList } from "@zenstackhq/runtime/hooks";
import TodoList from "components/TodoList";
function CreateDialog() {
const user = useContext(UserContext);
const [modalOpen, setModalOpen] = useState(false);
const [title, setTitle] = useState("");
const [_private, setPrivate] = useState(false);
const { create } = useList();
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
try {
await create({
data: {
title,
ownerId: user!.id,
private: _private,
},
});
} catch (err) {
alert(`Failed to create list: ${err}`);
return;
}
// reset states
setTitle("");
setPrivate(false);
// close modal
setModalOpen(false);
};
return (
<>
<input
type="checkbox"
id="create-list-modal"
className="modal-toggle"
checked={modalOpen}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setModalOpen(e.currentTarget.checked);
}}
/>
<div className="modal">
<div className="modal-box">
<h3 className="font-bold text-xl mb-8">Create a Todo list</h3>
<form onSubmit={onSubmit}>
<div className="flex flex-col space-y-4">
<div className="flex items-center">
<label htmlFor="title" className="text-lg inline-block w-20">
Title
</label>
<input
id="title"
type="text"
required
placeholder="Title of your list"
className="input input-bordered w-full max-w-xs mt-2"
value={title}
onChange={(e: FormEvent<HTMLInputElement>) => setTitle(e.currentTarget.value)}
/>
</div>
<div className="flex items-center">
<label htmlFor="private" className="text-lg inline-block w-20">
Private
</label>
<input
id="private"
type="checkbox"
className="checkbox"
onChange={(e: FormEvent<HTMLInputElement>) => setPrivate(e.currentTarget.checked)}
/>
</div>
</div>
<div className="modal-action">
<input className="btn btn-primary" type="submit" value="Create" />
<label htmlFor="create-list-modal" className="btn btn-outline">
Cancel
</label>
</div>
</form>
</div>
</div>
</>
);
}
export default function Home() {
const { find } = useList();
const { data: lists } = find({
include: {
owner: true,
},
orderBy: {
updatedAt: "desc",
},
});
return (
<>
<div className="p-8">
<div className="w-full flex flex-col md:flex-row mb-8 space-y-4 md:space-y-0 md:space-x-4">
<label htmlFor="create-list-modal" className="btn btn-primary btn-wide modal-button">
Create a list
</label>
</div>
<ul className="flex flex-wrap gap-6">
{lists?.map((list) => (
<li key={list.id}>
<TodoList value={list} />
</li>
))}
</ul>
<CreateDialog />
</div>
</>
);
}
The complete code of this version is under:
https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/list
Run it
Let’s log in with User A and create one public list and one private list like below:
Then let’s login with another User B, then you could only see the Public list
And If you try to delete this by user B you will see the error below:
Isn't it cool that all these things would be handled by the access policy that you don't need to worry about in your code? 😎
4. Todo
Let's implement the last part of this tutorial, the Todo. Let's begin with the ZModel, as always:
Data Structure
The data structure is like below:
model Todo {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
title String
completedAt DateTime?
}
With the experience of Todo list, there is nothing new for you except it has two reference field owner and list.
Access Policy
The access policy is like below:
// require login
@@deny('all', auth() == null)
// list owner has full access
@@allow('all', list.owner == auth())
// can be read by anyone if is public
@@allow('read', !list.private)
- The first one is the same as Todo.
- The second one means the owner of the list this Todo belongs to has the full access, which is done by referencing the list's field
list.owner
. Why don't we useowner == auth()
, like Todo? The problem is that then you could create a Todo under another person's public list. - It means if the list is public, then the Todo under it should be public too.
Write Your Own Code
Since you have been here, how about writing the Todo implementation by your own. Use this as a test to see how much you have learned about using the ZenStack library. 💪
Don't worry if you got blocked by something. You can always take a reference of our implementation below:
https://github.com/zenstackhq/zenstack-nextjs-todo-demo/tree/todo
Finally
Congratulations on finishing this long journey! ZenStack is an open-source project in its infant stage. If you think it's useful, let's raise it together. Let us know your thought on Twitter, Discord, GitHub, or whatever you like.
Coming next would be episode 2 which I will show you how to add space/organization to make it like an actual SAAS product
Posted on November 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 10, 2022