6.2 C
New York
Thursday, April 10, 2025

Easy methods to Construct a Multi-Tenant SaaS Software with Subsequent.js (Frontend Integration) — SitePoint


Within the first a part of this text collection, we carried out the backend with Appwrite, put in some dependencies, and arrange Allow to deal with authorization and role-based entry management. 

Now let’s have a look at how we will combine the frontend with the backend for a totally useful EdTech SaaS software.

Frontend Integration: Implementing Authorization in Subsequent.js

Now that you’ve got backend authorization in place utilizing Allow, combine it into your Subsequent.js frontend. The frontend ought to:

  • Fetch person permissions from the backend to manage what customers can see and do.
  • Guarantee API requests respect role-based entry management (RBAC).
  • Conceal UI parts for unauthorized customers (e.g., forestall college students from seeing “Create Task”).

1. Establishing API calls with authorization

Since solely the backend enforces permissions, your frontend by no means decides entry immediately—as a substitute, it:

  1. Sends requests to the backend
  2. Waits for the backend’s authorization response
  3. Shows information or UI parts accordingly

To get began, you’ll have to have Node.js put in in your pc.

Then, observe these steps, observe the steps beneath:

npx create-next-app@newest frontend
cd frontend

2. Initialize shadcn

What you’ll observe after the creation of your Nextjs challenge is that Tailwind CSS v4 is put in for you proper out of the field, which suggests you don’t have to do the rest. As a result of we’re making use of a part library, we’re going to set up Shadcn UI

To try this we have to run the init command to create a elements.json file within the root of the folder:

After initialization, you can begin including elements to your challenge:

npx shadcn@newest add button card dialog enter label desk choose tabs

If requested, in case you ought to use drive due to the Nextjs 15 model compatibility with shadcn, hit enter to proceed.

3. Set up wanted packages

Set up the next packages:

npm i lucide-react zustand
npm i --save-dev axios

Now that we now have put in all we have to construct our software, we will begin creating our different elements and routes.

To keep up UI consistency all through the appliance, paste this code into your world.css file (paste it beneath your tailwindcss import):

@layer base {
  :root {
    --background: 75 29% 95%;           
    --foreground: 0 0% 9%;              

    --card: 0 0% 100%;                  
    --card-foreground: 0 0% 9%;         

    --popover: 0 0% 99%;                
    --popover-foreground: 0 0% 9%;      

    --primary: 0 0% 0%;                 
    --primary-foreground: 60 100% 100%; 

    --secondary: 75 31% 95%;            
    --secondary-foreground: 0 0% 9%;    

    --muted: 69 30% 95%;                
    --muted-foreground: 0 0% 45%;       

    --accent: 252 29% 97%;              
    --accent-foreground: 0 0% 9%;       

    --destructive: 0 84.2% 60.2%;       
    --destructive-foreground: 0 0% 98%; 

    --border: 189 0% 45%;               
    --input: 155 0% 45%;                
    --ring: 0 0% 0%;                    

    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  physique {
    @apply bg-background text-foreground;
  }
}
physique {
  font-family: Arial, Helvetica, sans-serif;
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  physique {
    @apply bg-background text-foreground;
  }
}

4. Part recordsdata

Create the next part recordsdata and paste their corresponding code:

  • AddAssignmentDialog.tsx file:
"use consumer"

import kind React from "react"

import { useState } from "react"
import { Button } from "@/elements/ui/button"
import { Enter } from "@/elements/ui/enter"
import { Label } from "@/elements/ui/label"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/elements/ui/dialog"
import { Task } from "@/sorts"

interface AddAssignmentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddAssignment: (information: Task) => void
  creatorEmail: string
}

export perform AddAssignmentDialog({ open, onOpenChange, onAddAssignment, creatorEmail }: AddAssignmentDialogProps) {
  const [title, setTitle] = useState("")
  const [subject, setSubject] = useState("")
  const [teacher, setTeacher] = useState("")
  const [className, setClassName] = useState("")
  const [dueDate, setDueDate] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    const newAssignment = { title, topic, trainer, className, dueDate, creatorEmail }
    onAddAssignment(newAssignment)
    console.log("New project:", { title, topic, class: className, dueDate, creatorEmail })
    onOpenChange(false)
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Add New Task</DialogTitle>
          <DialogDescription>
            Enter the small print of the new project right here. Click on save whenever you're carried out.
          </DialogDescription>
        </DialogHeader>
        <kind onSubmit={handleSubmit}>
          <div className="grid gap-4 py-4">
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="title" className="text-right">
                Title
              </Label>
              <Enter id="title" worth={title} onChange={(e) => setTitle(e.goal.worth)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="topic" className="text-right">
                Topic
              </Label>
              <Enter id="topic" worth={topic} onChange={(e) => setSubject(e.goal.worth)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="trainer" className="text-right">
                Trainer
              </Label>
              <Enter id="trainer" worth={trainer} onChange={(e) => setTeacher(e.goal.worth)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="class" className="text-right">
                Class
              </Label>
              <Enter
                id="class"
                worth={className}
                onChange={(e) => setClassName(e.goal.worth)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="dueDate" className="text-right">
                Due Date
              </Label>
              <Enter
                id="dueDate"
                kind="date"
                worth={dueDate}
                onChange={(e) => setDueDate(e.goal.worth)}
                className="col-span-3"
              />
            </div>
          </div>
          <DialogFooter>
            <Button kind="submit">Save modifications</Button>
          </DialogFooter>
        </kind>
      </DialogContent>
    </Dialog>
  )
}

This file defines a React part, AddAssignmentDialog, which renders a dialog kind for including new assignments. It manages kind state utilizing useState and submits the project information to a mum or dad part by way of the onAddAssignment prop. The dialog contains enter fields for title, topic, trainer, class, and due date, and closes upon submission.

  • AddStudentDialog.tsx file:
'use consumer'

import { useState } from 'react'
import { Button } from '@/elements/ui/button'
import { Enter } from '@/elements/ui/enter'
import { Label } from '@/elements/ui/label'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/elements/ui/dialog'
import {
  Choose,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/elements/ui/choose"
import { Scholar } from '@/sorts'

interface AddStudentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddStudent: (information: Scholar) => void
  loading: boolean
  creatorEmail: string
}

export perform AddStudentDialog({ open, onOpenChange, onAddStudent, loading, creatorEmail }: AddStudentDialogProps) {
  const [firstName, setFirstName] = useState('')
  const [lastName, setLastName] = useState('')
  const [className, setClassName] = useState('')
  const [gender, setGender] = useState('')
  const [age, setAge] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    onAddStudent({
      firstName,
      lastName,
      className,
      gender,
      age: Quantity(age),
      creatorEmail
    })
    console.log('New scholar:', { firstName, lastName, className, gender, age })
    onOpenChange(false)
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Add New Scholar</DialogTitle>
          <DialogDescription>
            Enter the small print of the new scholar right here. Click on save whenever you're carried out.
          </DialogDescription>
        </DialogHeader>
        <kind onSubmit={handleSubmit}>
          <div className="grid gap-4 py-4">
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="firstName" className="text-right">
                First Title
              </Label>
              <Enter
                id="firstName"
                worth={firstName}
                onChange={(e) => setFirstName(e.goal.worth)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="lastName" className="text-right">
                Final Title
              </Label>
              <Enter
                id="lastName"
                worth={lastName}
                onChange={(e) => setLastName(e.goal.worth)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="class" className="text-right">
                Class
              </Label>
              <Enter
                id="class"
                worth={className}
                onChange={(e) => setClassName(e.goal.worth)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="gender" className="text-right">
                Gender
              </Label>
              <Choose onValueChange={setGender} worth={gender}>
                <SelectTrigger className="col-span-3">
                  <SelectValue placeholder="Choose gender" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem worth="boy">Boy</SelectItem>
                  <SelectItem worth="lady">Woman</SelectItem>
                </SelectContent>
              </Choose>
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="age" className="text-right">
                age
              </Label>
              <Enter
                id="age"
                kind="quantity"
                step="0.1"
                worth={age}
                min={"4"}
                max={"99"}
                placeholder='enter a sound age'
                onChange={(e) => setAge(e.goal.worth)}
                className="col-span-3"
              />
            </div>
          </div>
          <DialogFooter>
            <Button disabled={loading} kind="submit">{loading ? "Saving..." : "Save Modifications"}</Button>
          </DialogFooter>
        </kind>
      </DialogContent>
    </Dialog>
  )
}

This file defines a React part, AddStudentDialog, which renders a dialog kind for including new college students. It manages kind state utilizing useState and submits the coed information to a mum or dad part by way of the onAddStudent prop. The dialog contains enter fields for first identify, final identify, class, gender (with a dropdown), and age, and handles loading states throughout submission.

  • AssignmentsTable.tsx file:
import { Desk, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/elements/ui/desk"
import kind { AssignmentsTable } from "@/sorts"

export perform AssignmentsTables({ assignments }: { assignments: AssignmentsTable[] }) {
  console.log("Assignments", assignments)

  return (
    <Desk>
      <TableCaption>A checklist of current assignments.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Title</TableHead>
          <TableHead>Topic</TableHead>
          <TableHead>Class</TableHead>
          <TableHead>Trainer</TableHead>
          <TableHead>Due Date</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {assignments.map((project) => (
          <TableRow key={project.$id}>
            <TableCell>{project.title}</TableCell>
            <TableCell>{project.topic}</TableCell>
            <TableCell>{project.className}</TableCell>
            <TableCell>{project.trainer}</TableCell>
            <TableCell>{project.dueDate}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Desk>
  )
}

This file defines a React part, AssignmentsTables, which renders a desk to show a listing of assignments. It takes an array of assignments as props and maps by way of them to populate the desk rows with particulars like title, topic, class, trainer, and due date. The desk features a caption and headers for higher readability.

import kind React from "react"

interface AuthLayoutProps {
    kids: React.ReactNode
    title: string
    description?: string
}

export perform AuthLayout({ kids, title, description }: AuthLayoutProps) {
    return (
        <div className="min-h-screen grid lg:grid-cols-2">
            {}
            <div className="flex items-center justify-center p-8">
                <div className="mx-auto w-full max-w-sm space-y-6">
                    <div className="space-y-2 text-center">
                        <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
                        {description && <p className="text-sm text-muted-foreground">{description}</p>}
                    </div>
                    {kids}
                </div>
            </div>

            {}
            <div className="hidden lg:block relative bg-black">
                <div className="absolute inset-0 bg-[url('https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-xOOAKcDxPyvxlDygdNGtUvjEA6QHBO.png')] bg-cover bg-center opacity-50" />
                <div className="relative h-full flex items-center justify-center text-white p-12">
                    <div className="space-y-6 max-w-lg">
                        <h2 className="text-4xl font-bold">Hold Your Kids's Success</h2>
                        <p className="text-lg text-gray-200">
                            Join with lecturers, monitor progress, and keep concerned in your kid's schooling journey.
                        </p>
                    </div>
                </div>
            </div>
        </div>
    )
}

This file defines a React part, AuthLayout, which gives a format for authentication pages. It features a left aspect for types (with a title and optionally available description) and a proper aspect with a background picture and motivational textual content. The format is responsive, hiding the picture on smaller screens.

import { E-book, BarChart, MessageCircle } from "lucide-react"

const options = [
  {
    name: "Comprehensive Dashboard",
    description: "View student's overall academic performance, including average grades and progress over time.",
    icon: BarChart,
  },
  {
    name: "Easy Communication",
    description: "Direct messaging system between school administrators and teachers for quick and efficient communication.",
    icon: MessageCircle,
  },
  {
    name: "Academic Tracking",
    description:
      "Monitor assignments, upcoming tests, and project deadlines to help your students stay on top of their studies.",
    icon: Book,
  },
]

export perform Options() {
  return (
    <div className="py-12 bg-white" id="options">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="lg:text-center">
          <h2 className="text-base text-primary font-semibold tracking-wide uppercase">Options</h2>
          <p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
            All the things it's good to keep linked
          </p>
          <p className="mt-4 max-w-2xl text-xl text-gray-500 lg:mx-auto">
            Our platform affords a spread of options designed to reinforce communication between college directors and lecturers.
          </p>
        </div>

        <div className="mt-10">
          <dl className="space-y-10 md:space-y-0 md:grid md:grid-cols-3 md:gap-x-8 md:gap-y-10">
            {options.map((function) => (
              <div key={function.identify} className="relative">
                <dt>
                  <div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary text-white">
                    <function.icon className="h-6 w-6" aria-hidden="true" />
                  </div>
                  <p className="ml-16 text-lg leading-6 font-medium text-gray-900">{function.identify}</p>
                </dt>
                <dd className="mt-2 ml-16 text-base text-gray-500">{function.description}</dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
    </div>
  )
}

This file defines a React part, Options, which showcases key platform options in a visually interesting format. It features a title, description, and a grid of function playing cards, every with an icon, identify, and detailed description. The part is designed to spotlight the platform’s capabilities for varsity directors and lecturers.

This file defines a React part, Footer, which shows a easy footer with social media icons (Fb and Twitter) and a copyright discover. The footer is centered and responsive, with social hyperlinks on the suitable and the copyright textual content on the left for bigger screens.

This file defines a React part, Hero, which creates a visually participating hero part for a web site. It features a daring headline, a descriptive paragraph, and two call-to-action buttons (“Get began” and “Study extra”). The format encompasses a responsive design with a background form and a picture on the suitable aspect for bigger screens.

This file defines a React part, MobileMenu, which creates a responsive cellular navigation menu. It toggles visibility with a button and contains hyperlinks to options, about, and speak to sections, in addition to login and sign-up buttons. The menu is styled with a clear, fashionable design and closes when clicking the shut icon.

This file defines a React part, Navbar, which creates a responsive navigation bar with hyperlinks to options, about, and speak to sections. It contains login and sign-up buttons for bigger screens and integrates a MobileMenu part for smaller screens. The navbar is styled with a shadow and a centered format.

  • NotAuthorizedDialog.tsx file:

This file defines a React part, NotAuthorizedDialog, which shows a dialog when a person will not be licensed to carry out an motion. It features a title and outline prompting the person to contact an administrator, and its visibility is managed by way of the open and onOpenChange props.

This file defines a React part, StudentsTables, which renders a desk to show a listing of scholars. It takes an array of scholars as props and maps by way of them to populate the desk rows with particulars like first identify, final identify, class, gender, and age. The desk features a caption and headers for higher readability.

Seek advice from the GitHub code for the respective code of the elements talked about above.

State administration and kinds

Now for the subsequent step, we’ll be creating the state and kinds we’ll be utilizing all through the appliance. Create the retailer and types folders within the root of the challenge folder.

  • Inside the shop folder, create the next recordsdata and paste the corresponding code:
import { create } from "zustand"
import { persist } from "zustand/middleware"

interface Person {
  $id: string
  firstName: string
  lastName: string
  e-mail: string
}

interface AuthState  null;
  setToken: (token: string 

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      person: null,
      setUser: (person) => set({ person }),
      token: null,
      setToken: (token) => set({ token }),
      logout: () => set({ person: null }),
    }),
    {
      identify: "auth-storage", // Persist state in localStorage
    }
  )
)

This file defines a Zustand retailer, useAuthStore, for managing authentication state. It contains person and token states, together with strategies to set the person, set the token, and sign off. The state is continued in localStorage utilizing the persist middleware.

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface Profile {
  firstName: string;
  lastName: string;
  e-mail: string;
  position: string;
  userId: string;
  $id: string;
  $createdAt: string;
}

interface ProfileStore  null;
  setProfile: (profile: Profile) => void;
  clearProfile: () => void;


export const useProfileStore = create<ProfileStore>()(
  persist(
    (set) => ({
      profile: null,
      setProfile: (profile) => set({ profile }),
      clearProfile: () => set({ profile: null }),
    }),
    {
      identify: "profile-storage", 
    }
  )
);

This file defines a Zustand retailer, useProfileStore, for managing person profile information. It features a profile state and strategies to set and clear the profile. The state is continued in localStorage utilizing the persist middleware.

  • Inside the categories folder, create the next file and paste the next code within the index.ts file:
export interface Task {
  title: string;
  topic: string;
  className: string;
  trainer: string;
  dueDate: string;
  creatorEmail: string;
}

export interface AssignmentsTable extends Task {
  $id: string;
  }

export interface Scholar {
  firstName: string;
  lastName: string;
  gender: string;
  className: string;
  age: quantity;
  creatorEmail: string;
}

export interface StudentsTable extends Scholar {
  $id: string;
}

This file defines TypeScript interfaces for Task, AssignmentsTable, Scholar, and StudentsTable. It extends the bottom Task and Scholar interfaces with extra properties like $id for database information, making certain constant typing throughout the appliance.

Routes

Now we get to see how the elements and retailer we simply created are getting used within the software.

Substitute the code within the app/web page.tsx file with the code beneath:

import { Navbar } from "@/elements/Navbar"
import { Hero } from "@/elements/Hero"
import { Options } from "@/elements/Options"
import { Footer } from "@/elements/Footer"

export default perform Dwelling() {
  return (
    <div className="min-h-screen flex flex-col">
      <Navbar />
      <important className="flex-grow">
        <Hero />
        <Options />
      </important>
      <Footer />
    </div>
  )
}

This file defines the principle house web page part, which buildings the format utilizing Navbar, Hero, Options, and Footer elements. It ensures a responsive design with a flex format and full-page peak.

Create the next folders within the app folder and paste this code of their respective web page.tsx recordsdata:

  • Create a signup folder and paste this code in its web page.tsx file:
"use consumer"

import { useState } from "react"
import Hyperlink from "subsequent/hyperlink"
import { useRouter } from "subsequent/navigation"
import { Button } from "@/elements/ui/button"
import { Enter } from "@/elements/ui/enter"
import { Label } from "@/elements/ui/label"
import { AuthLayout } from "@/elements/auth-layout"
import { useAuthStore } from "@/retailer/auth" 

export default perform SignupPage() {
  const router = useRouter()
  const { setUser, setToken } = useAuthStore() 
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async perform onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget as HTMLFormElement);
    const userData = {
        identify: `${formData.get("firstName")} ${formData.get("lastName")}`,
        e-mail: formData.get("e-mail"),
        password: formData.get("password"),
    };

    attempt {
        const response = await fetch("https://edtech-saas-backend.vercel.app/api/auth/signup", {
            methodology: "POST",
            headers: { "Content material-Kind": "software/json" },
            physique: JSON.stringify(userData),
        });

        const consequence = await response.json();

        if (!response.okay || !consequence.success) {
            throw new Error("Signup failed. Please attempt once more.");
        }

        console.log("Signup profitable:", consequence);

        
        const [firstName, ...lastNameParts] = consequence.person.identify.cut up(" ");
        const lastName = lastNameParts.be part of(" ") || ""; 

        
        setUser({
            $id: consequence.person.$id,
            firstName,
            lastName,
            e-mail: consequence.person.e-mail,
        });
        setToken(consequence.token);
        console.log("Person:", consequence.person);
        console.log("Token:", consequence.token)
        router.push("/role-selection");
    } catch (err)  "An error occurred");
        console.error("Error:", error);
     lastly {
        setIsLoading(false);
    }
}

  return (
    <AuthLayout title="Create an account" description="Enter your particulars to get began">
      <kind onSubmit={onSubmit} className="space-y-4">
        <div className="grid gap-4 grid-cols-2">
          <div className="space-y-2">
            <Label htmlFor="firstName">First identify</Label>
            <Enter identify="firstName" id="firstName" placeholder="John" disabled={isLoading} required />
          </div>
          <div className="space-y-2">
            <Label htmlFor="lastName">Final identify</Label>
            <Enter identify="lastName" id="lastName" placeholder="Doe" disabled={isLoading} required />
          </div>
        </div>
        <div className="space-y-2">
          <Label htmlFor="e-mail">Electronic mail</Label>
          <Enter identify="e-mail" id="e-mail" placeholder="identify@instance.com" kind="e-mail" autoComplete="e-mail" disabled={isLoading} required />
        </div>
        <div className="space-y-2">
          <Label htmlFor="password">Password</Label>
          <Enter identify="password" id="password" kind="password" disabled={isLoading} required />
        </div>
        {error && <p className="text-red-500 text-sm">{error}</p>}
        <Button className="w-full" kind="submit" disabled={isLoading}>
          {isLoading ? "Creating account..." : "Create account"}
        </Button>
      </kind>
      <div className="text-center text-sm">
        <Hyperlink href="/login" className="underline underline-offset-4 hover:text-primary">
          Have already got an account? Signal in
        </Hyperlink>
      </div>
    </AuthLayout>
  )
}

This file defines a SignupPage part for person registration, dealing with kind submission with validation and error dealing with. It makes use of Zustand to retailer person information and a token upon profitable signup, then redirects to a job choice web page. The shape contains fields for first identify, final identify, e-mail, and password, with a hyperlink to the login web page for present customers.

  • Create a role-selection folder and paste this code in its web page.tsx file:
"use consumer"

import { useState } from "react"
import { useRouter } from "subsequent/navigation"
import { Button } from "@/elements/ui/button"
import { Card, CardContent } from "@/elements/ui/card"
import { GraduationCap, Customers } from "lucide-react"
import { useAuthStore } from "@/retailer/auth"
import { useProfileStore } from "@/retailer/profile"

const roles = [
  {
      id: "Admin",
      title: "Admin",
      description: "Manage teachers, classes, and more",
      icon: GraduationCap,
  },
  {
    id: "Teacher",
    title: "Teacher",
    description: "Access your class dashboard, manage grades, and communicate with students",
    icon: GraduationCap,
  },
  {
    id: "Student",
    title: "Student",
    description: "Monitor your progress and communicate with teachers",
    icon: Users,
  },
]

export default perform RoleSelectionPage() {
  const { person, token } = useAuthStore()
  const { setProfile } = useProfileStore()
  console.log("Person:", person);
  const router = useRouter()
  const [selectedRole, setSelectedRole] = useState<string | null>(null)
  console.log("Chosen Position:", selectedRole);
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async perform onSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!selectedRole || !person) return
    setIsLoading(true)
    setError(null)

    const formattedRole =
      selectedRole.charAt(0).toUpperCase() + selectedRole.slice(1).toLowerCase(); 

    const payload = {
      firstName: person?.firstName,
      lastName: person?.lastName,
      e-mail: person?.e-mail,
      position: formattedRole,
      userId: person?.$id,
    }
    console.log("Payload", payload)

    attempt {
      const response = await fetch("https://edtech-saas-backend.vercel.app/api/profile", {
        methodology: "POST",
        headers: {
          "Authorization": `Bearer ${token}`,
          "Content material-Kind": "software/json"
        },
        physique: JSON.stringify(payload),
      })

      const information = await response.json()
      if (!response.okay)  "Did not create profile")
      
      console.log("Profile Knowledge", information)
      setProfile({
        firstName: information?.person?.firstName,
        lastName: information?.person?.lastName,
        e-mail: information?.person?.e-mail,
        position: information?.person?.position,
        userId: information?.person?.userId,
        $id: information?.person?.$id,
        $createdAt: information?.person?.$createdAt,
      })
      router.push("/dashboard")
    } catch (err) {
      const error = err as Error
      setError(error.message)
      console.error("Error:", error)
    } lastly {
      setIsLoading(false)
    }
  }

  return (
      <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
        <div className="max-w-md w-full space-y-8">
          <div className="text-center space-y-2">
            <h1 className="text-3xl font-bold">Choose your position</h1>
            <p className="text-gray-500">Select your position to entry the suitable dashboard</p>
          </div>
          {error && <p className="text-red-500 text-center">{error}</p>}
          <kind onSubmit={onSubmit} className="space-y-4">
            <div className="grid gap-4">
              {roles.map((position) => {
                const Icon = position.icon
                return (
                    <Card
                    key={position.id}
                    className={`cursor-pointer transition-colors ${selectedRole === position.id ? "border-black" : ""}`}
                    onClick={() => setSelectedRole(position.title)}
                    >
                      <CardContent className="flex items-start gap-4 p-6">
                        <div className="rounded-full p-2 bg-gray-100">
                          <Icon className="h-6 w-6" />
                        </div>
                        <div className="space-y-1">
                          <h3 className="font-medium">{position.title}</h3>
                          <p className="text-sm text-gray-500">{position.description}</p>
                        </div>
                      </CardContent>
                    </Card>
                )
              })}
            </div>

            <Button className="w-full" kind="submit" disabled= isLoading>
              {isLoading ? "Confirming..." : "Proceed"}
            </Button>
          </kind>
        </div>
      </div>
  )
}

This file defines a RoleSelectionPage part the place customers choose their position (Admin, Trainer, or Scholar) after signing up. It handles position choice, submits the information to create a profile, and redirects to the dashboard upon success. The UI contains playing cards for every position, a affirmation button, and error dealing with.

  • Create a login folder and paste this code in its web page.tsx file:
"use consumer";

import { useState } from "react";
import Hyperlink from "subsequent/hyperlink";
import { useRouter } from "subsequent/navigation";
import { Button } from "@/elements/ui/button";
import { Enter } from "@/elements/ui/enter";
import { Label } from "@/elements/ui/label";
import { AuthLayout } from "@/elements/auth-layout";
import { useAuthStore } from "@/retailer/auth";
import { useProfileStore } from "@/retailer/profile";

export default perform LoginPage() {
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(false);
  const { setUser, setToken } = useAuthStore() 
  const [formData, setFormData] = useState({ e-mail: "", password: "" });
  const [error, setError] = useState<string | null>(null)

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormData({ ...formData, [e.target.name]: e.goal.worth });
  };

  async perform onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
 
    console.log("FormData", formData);
 
    attempt {
      
      const authResponse = await fetch("https://edtech-saas-backend.vercel.app/api/auth/login", {
        methodology: "POST",
        headers: {
          "Content material-Kind": "software/json",
        },
        physique: JSON.stringify(formData),
      });
 
      if (!authResponse.okay) throw new Error("Invalid credentials");
 
      const authData = await authResponse.json();
      console.log("Auth Consequence:", authData);
 
      const token = authData.token;
      setToken(token);
 
      setUser({
        $id: authData.session.$id,
        firstName: "",
        lastName: "",
        e-mail: authData.session.providerUid,
      });
 
      
      const profileResponse = await fetch(`https://edtech-saas-backend.vercel.app/api/profile/${formData.e-mail}`, {
        methodology: "GET",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content material-Kind": "software/json",
        },
      });
 
      if (!profileResponse.okay) throw new Error("Did not fetch person profile");
 
      const profileData = await profileResponse.json();
      console.log("Profile Knowledge:", profileData);
 
      if (profileData.profile) {
        
        useProfileStore.getState().setProfile(profileData.profile);
        router.push("/dashboard");
      } else {
        router.push("/role-selection");
      }
    } catch (err)  "An error occurred");
     lastly {
      setIsLoading(false);
    }
  }
 

  return (
    <AuthLayout title="Welcome again" description="Enter your credentials to entry your account">
      <kind onSubmit={onSubmit} className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="e-mail">Electronic mail</Label>
          <Enter
            id="e-mail"
            identify="e-mail"
            placeholder="identify@instance.com"
            kind="e-mail"
            autoCapitalize="none"
            autoComplete="e-mail"
            autoCorrect="off"
            disabled={isLoading}
            required
            onChange={handleChange}
          />
        </div>
        <div className="space-y-2">
          <Label htmlFor="password">Password</Label>
          <Enter
            id="password"
            identify="password"
            kind="password"
            disabled={isLoading}
            required
            onChange={handleChange}
          />
        </div>
        {error && <p className="text-red-500 text-sm">{error}</p>}
        <Button className="w-full" kind="submit" disabled={isLoading}>
          {isLoading ? "Signing in..." : "Sign up"}
        </Button>
      </kind>
      <div className="text-center text-sm">
        <Hyperlink href="/signup" className="underline underline-offset-4 hover:text-primary">
          Do not have an account? Join
        </Hyperlink>
      </div>
    </AuthLayout>
  );
}

This file defines a LoginPage part for person authentication, dealing with kind submission with e-mail and password. It makes use of Zustand to retailer person information and a token, fetches the person’s profile, and redirects to the dashboard or position choice web page based mostly on the profile standing. The shape contains error dealing with and a hyperlink to the signup web page for brand spanking new customers.

  • Create a dashboard folder and paste this code in its web page.tsx file:
"use consumer";

import { useState, useEffect } from "react";
import { StudentsTables } from "@/elements/StudentsTable";
import { Button } from "@/elements/ui/button";
import { NotAuthorizedDialog } from "@/elements/NotAuthorizedDialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/elements/ui/tabs";
import { useAuthStore } from "@/retailer/auth";
import { useProfileStore } from "@/retailer/profile";
import { AddStudentDialog } from "@/elements/AddStudentDialog";
import { AddAssignmentDialog } from "@/elements/AddAssignmentDialog";
import {Task,  AssignmentsTable, Scholar, StudentsTable } from "@/sorts";
import { AssignmentsTables } from "@/elements/AssignmentsTable";
import axios from "axios";

export default perform TeacherDashboard() {
  const { token, logout } = useAuthStore();
  const { profile, clearProfile } = useProfileStore();
  const [isNotAuthorizedDialogOpen, setIsNotAuthorizedDialogOpen] = useState(false);
  const [isAddStudentDialogOpen, setIsAddStudentDialogOpen] = useState(false);
  const [isAddAssignmentDialogOpen, setIsAddAssignmentDialogOpen] = useState(false);
  const [students, setStudents] = useState<StudentsTable[]>([]);
  const [assignments, setAssignments] = useState<AssignmentsTable[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");
 

  const API_URL_STUDENTS = "https://edtech-saas-backend.vercel.app/api/college students";
  const API_URL_ASSIGNMENTS = "https://edtech-saas-backend.vercel.app/api/assignments/create";

 

  async perform fetchData() {
    setLoading(true);
    setError("");
 
    const headers = {
      "Content material-Kind": "software/json",
      Authorization: `Bearer ${token}`,
    };
 
    const e-mail = profile?.e-mail;
    if (!e-mail) {
      setError("Electronic mail is required");
      return;
    }
 
    
    const studentsUrl = `https://edtech-saas-backend.vercel.app/api/college students/${e-mail}`;
    const assignmentsUrl = `https://edtech-saas-backend.vercel.app/api/assignments/${e-mail}`;
 
    
    attempt {
      const studentsRes = await axios.get(studentsUrl, { headers });
      console.log("College students Knowledge:", studentsRes.information);
      setStudents(studentsRes.information);
    } catch (err) {
      console.warn("Did not fetch college students information:", err);
      setStudents([]); 
    }
 
    
    attempt {
      const assignmentsRes = await axios.get(assignmentsUrl, { headers });
      console.log("Assignments Knowledge:", assignmentsRes.information);
      setAssignments(assignmentsRes.information);
    } catch (err) {
      console.error("Error fetching assignments information:", err);
      setError((err as Error).message);
    } lastly {
      setLoading(false);
    }
  }
 
 
 
  useEffect(() => {
    if (!token) return;

    fetchData();
  }, [token]);

    const handleAddStudent = async (information: Omit<Scholar, 'creatorEmail'>) => {
    setLoading(true);
    setError("");
 
    const payload = {
      firstName: information.firstName,
      lastName: information.lastName,
      gender: information.gender,
      className: information.className,
      age: information.age,
      creatorEmail: profile?.e-mail,
    };
    console.log("College students payload:", payload);
 
    attempt {
      const response = await fetch(API_URL_STUDENTS, {
        methodology: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content material-Kind": "software/json",
        },
        physique: JSON.stringify(payload),
      });
 
      const consequence = await response.json(); 
      console.log("Scholar Consequence", consequence);
 
      if (response.standing === 403 && consequence.message === "Not licensed") {
        setIsAddStudentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return; 
      }
 
      if (!response.okay) throw new Error(consequence.message || "Failed so as to add scholar");
 
      setStudents((prevStudents: Scholar[]) => [...prevStudents, result]); 
      setIsAddStudentDialogOpen(false);
      await fetchData();
    } catch (err) {
      if ((err as Error & { code?: quantity }).code === 403 && (err as Error).message === "Not licensed") {
        setIsAddStudentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return;
      }
      setError((err as Error).message);
      console.error("Error:", err);
    } lastly {
      setLoading(false);
    }
  };
 

    const handleAddAssignment = async (information: Task) => {
    setLoading(true);
    setError("");
 
    const payload = {
      title: information.title,
      topic: information.topic,
      className: information.className,
      trainer: information.trainer,
      dueDate: information.dueDate,
      creatorEmail: profile?.e-mail,
    };
 
    attempt {
      const response = await fetch(API_URL_ASSIGNMENTS, {
        methodology: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content material-Kind": "software/json",
        },
        physique: JSON.stringify(payload),
      });
 
      const consequence = await response.json(); 
 
      if (response.standing === 403 && consequence.message === "Not licensed") {
        setIsAddAssignmentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return; 
      }
 
      if (!response.okay) throw new Error(consequence.message || "Failed so as to add project");
 
      setAssignments((prevAssignments: Task[]) => [...prevAssignments, result]); 
      setIsAddAssignmentDialogOpen(false);
    } catch (err) {
      if ((err as Error & { code?: quantity }).code === 403 && (err as Error).message === "Not licensed") {
        setIsAddAssignmentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return;
      }
      setError((err as Error).message);
      console.error("Error:", err);
    } lastly {
      setLoading(false);
    }
  };

  const handleLogout = () => {
    clearProfile();
    logout();
    window.location.href = "/login";
  };

  return (
    <div className="container mx-auto p-4">
      <div className="flex items-center justify-between mb-4">
        <div>
          <h1 className="text-2xl font-bold mb-2">Welcome {profile?.firstName}</h1>
          <p className="text-gray-600 mb-6">
            You might be logged in as {profile?.position === "Admin" ? "an" : "a"} {profile?.position}.
          </p>
        </div>
        <Button variant="default" onClick={handleLogout}>Log off</Button>
      </div>

      {profile?.position === 'Scholar'
      ?  (
        <div>
          <AssignmentsTables assignments={assignments} />
        </div>
        )
        : (
          <Tabs defaultValue="college students" className="w-full">
            <TabsList className="grid w-full grid-cols-2">
              <TabsTrigger worth="college students">College students</TabsTrigger>
              <TabsTrigger worth="assignments">Assignments</TabsTrigger>
            </TabsList>

            <TabsContent worth="college students">
              <StudentsTables college students={college students} />
              <Button onClick={() => setIsAddStudentDialogOpen(true)}>Add a Scholar</Button>
            </TabsContent>

            <TabsContent worth="assignments">
              <AssignmentsTables assignments={assignments} />
              <Button onClick={() => setIsAddAssignmentDialogOpen(true)}>Add Task</Button>
            </TabsContent>
          </Tabs>
        )}

      {error && <p className="text-red-500 mt-4">{error}</p>}

      <NotAuthorizedDialog open={isNotAuthorizedDialogOpen} onOpenChange={setIsNotAuthorizedDialogOpen} />
      <AddStudentDialog creatorEmail= "" loading={loading} open={isAddStudentDialogOpen} onOpenChange={setIsAddStudentDialogOpen} onAddStudent={handleAddStudent} />
      <AddAssignmentDialog creatorEmail= "" open={isAddAssignmentDialogOpen} onOpenChange={setIsAddAssignmentDialogOpen} onAddAssignment={handleAddAssignment} />
    </div>
  );
}

This file defines a TeacherDashboard part that shows a dashboard for lecturers or admins, permitting them to handle college students and assignments. It contains tabs for switching between college students and assignments, buttons so as to add new entries, and handles authorization errors. The part fetches and shows information based mostly on the person’s position, with a logout choice and error dealing with.

After creating all of the recordsdata and elements above and utilizing them as I’ve proven you, your software ought to work whenever you run this command beneath:

The app will likely be obtainable at http://localhost:3000/.

Check out the appliance now by creating a college, signing up and logging in as an admin, trainer or scholar, and performing some actions.

Constructing a multi-tenant EdTech SaaS software with Subsequent.js, Appwrite, and Allow supplied a number of insights into authorization, safety, and scalability. Listed here are the important thing takeaways:

  • Simplified Position-Based mostly Entry Management (RBAC): With Allow, defining and implementing admin, trainer, and scholar roles was easy. As a substitute of hardcoding permissions, I might dynamically handle them by way of the Allow UI.
  • Allow’s tenant-aware insurance policies ensured that colleges (tenants) remained remoted from each other. This was necessary for information safety in a multi-tenant SaaS app.
  • As a substitute of writing and managing customized permission logic throughout dozens of API routes, Allow dealt with entry management in a centralized strategy to cut back complexity and make future updates simpler.
  • Since all authorization checks had been enforced on the backend, the frontend solely displayed UI parts based mostly on permissions, making certain a easy person expertise.
  • Implementing customized authentication from scratch might have taken weeks. However utilizing Appwrite for authentication and Allow for authorization, I used to be capable of concentrate on constructing core options as a substitute of reinventing entry management.

Conclusion

Integrating Allow with Subsequent.js & Appwrite enabled me to simplify authorization in my multi-tenant Edtech SaaS software. By offloading complicated permission logic to Allow, I used to be capable of concentrate on constructing options, not managing entry management manually.

If you happen to’re constructing a SaaS app with complicated permissions & multi-tenancy, Allow is a superb software to make use of to streamline your workflow.

Entry the GitHub repo of the completed challenge for the backend right here and the frontend right here.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles