diff --git a/actions/generate-user-stripe.ts b/actions/generate-user-stripe.ts index 3de168e7..5c143d9c 100644 --- a/actions/generate-user-stripe.ts +++ b/actions/generate-user-stripe.ts @@ -19,12 +19,13 @@ export async function generateUserStripe(priceId: string): Promise{children} +export default async function AuthLayout({ children }: AuthLayoutProps) { + const user = await getCurrentUser(); + + if (user) { + if (user.role === "ADMIN") redirect("/admin"); + redirect("/dashboard"); + } + + return
{children}
; } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 54a51ae7..2f1eeb31 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,16 +1,16 @@ -import { Metadata } from "next" -import Link from "next/link" +import { Suspense } from "react"; +import { Metadata } from "next"; +import Link from "next/link"; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" -import { Icons } from "@/components/shared/icons" -import { UserAuthForm } from "@/components/forms/user-auth-form" -import { Suspense } from "react" +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { UserAuthForm } from "@/components/forms/user-auth-form"; +import { Icons } from "@/components/shared/icons"; export const metadata: Metadata = { title: "Login", description: "Login to your account", -} +}; export default function LoginPage() { return ( @@ -19,7 +19,7 @@ export default function LoginPage() { href="/" className={cn( buttonVariants({ variant: "outline", size: "sm" }), - "absolute left-4 top-4 md:left-8 md:top-8" + "absolute left-4 top-4 md:left-8 md:top-8", )} > <> @@ -50,5 +50,5 @@ export default function LoginPage() {

- ) + ); } diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 5c00223b..5983b9de 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -14,7 +14,7 @@ export default async function PricingPage() { const user = await getCurrentUser(); let subscriptionPlan; - if (user) { + if (user && user.id) { subscriptionPlan = await getUserSubscriptionPlan(user.id); } diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx new file mode 100644 index 00000000..aa2c16c0 --- /dev/null +++ b/app/(protected)/admin/page.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { DashboardHeader } from "@/components/dashboard/header"; +import InfoCard from "@/components/dashboard/info-card"; +import { DashboardShell } from "@/components/dashboard/shell"; +import TransactionsList from "@/components/dashboard/transactions-list"; + +export const metadata = constructMetadata({ + title: "Admin – SaaS Starter", + description: "Admin page for only admin management.", +}); + +export default async function AdminPage() { + const user = await getCurrentUser(); + if (!user || user.role !== "ADMIN") redirect("/login"); + + return ( + + +
+
+ + + +
+ +
+
+ ); +} diff --git a/app/(dashboard)/dashboard/billing/loading.tsx b/app/(protected)/dashboard/billing/loading.tsx similarity index 100% rename from app/(dashboard)/dashboard/billing/loading.tsx rename to app/(protected)/dashboard/billing/loading.tsx diff --git a/app/(dashboard)/dashboard/billing/page.tsx b/app/(protected)/dashboard/billing/page.tsx similarity index 88% rename from app/(dashboard)/dashboard/billing/page.tsx rename to app/(protected)/dashboard/billing/page.tsx index 404f661d..a4042213 100644 --- a/app/(dashboard)/dashboard/billing/page.tsx +++ b/app/(protected)/dashboard/billing/page.tsx @@ -1,13 +1,13 @@ import { redirect } from "next/navigation"; -import { BillingInfo } from "@/components/pricing/billing-info"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { DashboardShell } from "@/components/dashboard/shell"; -import { Icons } from "@/components/shared/icons"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { getCurrentUser } from "@/lib/session"; import { getUserSubscriptionPlan } from "@/lib/subscription"; import { constructMetadata } from "@/lib/utils"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { DashboardHeader } from "@/components/dashboard/header"; +import { DashboardShell } from "@/components/dashboard/shell"; +import { BillingInfo } from "@/components/pricing/billing-info"; +import { Icons } from "@/components/shared/icons"; export const metadata = constructMetadata({ title: "Billing – SaaS Starter", @@ -17,12 +17,13 @@ export const metadata = constructMetadata({ export default async function BillingPage() { const user = await getCurrentUser(); - if (!user) { + let userSubscriptionPlan; + if (user && user.id && user.role === "USER") { + userSubscriptionPlan = await getUserSubscriptionPlan(user.id); + } else { redirect("/login"); } - const userSubscriptionPlan = await getUserSubscriptionPlan(user.id); - return ( This is a demo app. - + SaaS Starter app is a demo app using a Stripe test environment. You can find a list of test card numbers on the{" "} - +
diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx similarity index 78% rename from app/(dashboard)/dashboard/page.tsx rename to app/(protected)/dashboard/page.tsx index d58afbc4..00b9d56d 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(protected)/dashboard/page.tsx @@ -1,27 +1,24 @@ -import { redirect } from "next/navigation"; - +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardShell } from "@/components/dashboard/shell"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; -import { Button } from "@/components/ui/button"; -import { getCurrentUser } from "@/lib/session"; -import { constructMetadata } from "@/lib/utils"; export const metadata = constructMetadata({ - title: "Settings – SaaS Starter", - description: "Overview of your account and activities.", + title: "Panel – SaaS Starter", + description: "Create and manage content.", }); export default async function DashboardPage() { const user = await getCurrentUser(); - if (!user) { - redirect("/login"); - } - return ( - +
diff --git a/app/(dashboard)/dashboard/settings/loading.tsx b/app/(protected)/dashboard/settings/loading.tsx similarity index 95% rename from app/(dashboard)/dashboard/settings/loading.tsx rename to app/(protected)/dashboard/settings/loading.tsx index 9304c36c..f7779856 100644 --- a/app/(dashboard)/dashboard/settings/loading.tsx +++ b/app/(protected)/dashboard/settings/loading.tsx @@ -12,6 +12,7 @@ export default function DashboardSettingsLoading() {
+
); diff --git a/app/(dashboard)/dashboard/settings/page.tsx b/app/(protected)/dashboard/settings/page.tsx similarity index 85% rename from app/(dashboard)/dashboard/settings/page.tsx rename to app/(protected)/dashboard/settings/page.tsx index 4ecbb0a4..68be8cf8 100644 --- a/app/(dashboard)/dashboard/settings/page.tsx +++ b/app/(protected)/dashboard/settings/page.tsx @@ -6,6 +6,7 @@ import { DeleteAccountSection } from "@/components/dashboard/delete-account"; import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardShell } from "@/components/dashboard/shell"; import { UserNameForm } from "@/components/forms/user-name-form"; +import { UserRoleForm } from "@/components/forms/user-role-form"; export const metadata = constructMetadata({ title: "Settings – SaaS Starter", @@ -15,9 +16,7 @@ export const metadata = constructMetadata({ export default async function SettingsPage() { const user = await getCurrentUser(); - if (!user) { - redirect("/login"); - } + if (!user?.id) redirect("/login"); return ( @@ -27,6 +26,7 @@ export default async function SettingsPage() { />
+
diff --git a/app/(dashboard)/layout.tsx b/app/(protected)/layout.tsx similarity index 60% rename from app/(dashboard)/layout.tsx rename to app/(protected)/layout.tsx index 815144aa..9c14dd1f 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -1,22 +1,38 @@ +import { redirect } from "next/navigation"; + +import { dashboardConfig } from "@/config/dashboard"; +import { getCurrentUser } from "@/lib/session"; import { DashboardNav } from "@/components/layout/dashboard-sidenav"; import { NavBar } from "@/components/layout/navbar"; import { SiteFooter } from "@/components/layout/site-footer"; import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; -import { dashboardConfig } from "@/config/dashboard"; -interface DashboardLayoutProps { +import { adminConfig } from "../../config/admin"; + +interface ProtectedLayoutProps { children?: React.ReactNode; } -export default function DashboardLayout({ children }: DashboardLayoutProps) { +export default async function ProtectedLayout({ + children, +}: ProtectedLayoutProps) { + const user = await getCurrentUser(); + + if (!user) redirect("/login"); + return (
-
{children} diff --git a/auth.config.ts b/auth.config.ts index 04e5f255..1c263fb8 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,7 +1,9 @@ -import Google from "next-auth/providers/google" -import { env } from "@/env.mjs" +import type { NextAuthConfig } from "next-auth"; +import Google from "next-auth/providers/google"; +import Resend from "next-auth/providers/resend"; + +import { env } from "@/env.mjs"; -import type { NextAuthConfig } from "next-auth" // import { siteConfig } from "@/config/site" // import { getUserByEmail } from "@/lib/user"; // import MagicLinkEmail from "@/emails/magic-link-email" @@ -13,41 +15,9 @@ export default { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }), - // Email({ - // sendVerificationRequest: async ({ identifier, url, provider }) => { - // const user = await getUserByEmail(identifier); - // if (!user || !user.name) return null; - - // const userVerified = user?.emailVerified ? true : false; - // const authSubject = userVerified ? `Sign-in link for ${siteConfig.name}` : "Activate your account"; - - // try { - // const { data, error } = await resend.emails.send({ - // from: 'SaaS Starter App ', - // to: process.env.NODE_ENV === "development" ? 'delivered@resend.dev' : identifier, - // subject: authSubject, - // react: MagicLinkEmail({ - // firstName: user?.name as string, - // actionUrl: url, - // mailType: userVerified ? "login" : "register", - // siteName: siteConfig.name - // }), - // // Set this to prevent Gmail from threading emails. - // // More info: https://resend.com/changelog/custom-email-headers - // headers: { - // 'X-Entity-Ref-ID': new Date().getTime() + "", - // }, - // }); - - // if (error || !data) { - // throw new Error(error?.message) - // } - - // // console.log(data) - // } catch (error) { - // throw new Error("Failed to send verification email.") - // } - // }, - // }), + Resend({ + apiKey: env.RESEND_API_KEY, + from: "SaaS Starter App ", + }), ], -} satisfies NextAuthConfig \ No newline at end of file +} satisfies NextAuthConfig; diff --git a/auth.ts b/auth.ts index 14c024ad..b524e7b7 100644 --- a/auth.ts +++ b/auth.ts @@ -1,10 +1,21 @@ -import NextAuth from "next-auth" -import { PrismaAdapter } from "@auth/prisma-adapter" -import { prisma } from "@/lib/db" -import authConfig from "@/auth.config" -import { getUserById } from "@/lib/user" +import authConfig from "@/auth.config"; +import { PrismaAdapter } from "@auth/prisma-adapter"; +import { UserRole } from "@prisma/client"; +import NextAuth, { type DefaultSession } from "next-auth"; -export const { +import { prisma } from "@/lib/db"; +import { getUserById } from "@/lib/user"; + +// More info: https://authjs.dev/getting-started/typescript#module-augmentation +declare module "next-auth" { + interface Session { + user: { + role: UserRole; + } & DefaultSession["user"]; + } +} + +export const { handlers: { GET, POST }, auth, } = NextAuth({ @@ -20,16 +31,20 @@ export const { if (token.sub) { session.user.id = token.sub; } - + if (token.email) { session.user.email = token.email; } + if (token.role) { + session.user.role = token.role; + } + session.user.name = token.name; session.user.image = token.picture; } - return session + return session; }, async jwt({ token }) { @@ -42,10 +57,11 @@ export const { token.name = dbUser.name; token.email = dbUser.email; token.picture = dbUser.image; + token.role = dbUser.role; return token; }, }, ...authConfig, // debug: process.env.NODE_ENV !== "production" -}) \ No newline at end of file +}); diff --git a/components/dashboard/info-card.tsx b/components/dashboard/info-card.tsx new file mode 100644 index 00000000..54977199 --- /dev/null +++ b/components/dashboard/info-card.tsx @@ -0,0 +1,23 @@ +import { Users } from "lucide-react" + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export default function InfoCard() { + return ( + + + Subscriptions + + + +
+2350
+

+180.1% from last month

+
+
+ ) +} diff --git a/components/dashboard/transactions-list.tsx b/components/dashboard/transactions-list.tsx new file mode 100644 index 00000000..5cb0b0d8 --- /dev/null +++ b/components/dashboard/transactions-list.tsx @@ -0,0 +1,148 @@ +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function TransactionsList() { + return ( + + +
+ Transactions + + Recent transactions from your store. + +
+ +
+ + + + + Customer + Type + Status + Date + Amount + + + + + +
Liam Johnson
+
+ liam@example.com +
+
+ Sale + + + Approved + + + + 2023-06-23 + + $250.00 +
+ + +
Olivia Smith
+
+ olivia@example.com +
+
+ Refund + + + Declined + + + + 2023-06-24 + + $150.00 +
+ + +
Noah Williams
+
+ noah@example.com +
+
+ + Subscription + + + + Approved + + + + 2023-06-25 + + $350.00 +
+ + +
Emma Brown
+
+ emma@example.com +
+
+ Sale + + + Approved + + + + 2023-06-26 + + $450.00 +
+ + +
Liam Johnson
+
+ liam@example.com +
+
+ Sale + + + Approved + + + + 2023-06-27 + + $550.00 +
+
+
+
+
+ ); +} diff --git a/components/forms/user-auth-form.tsx b/components/forms/user-auth-form.tsx index 774ffe64..c9f3fec2 100644 --- a/components/forms/user-auth-form.tsx +++ b/components/forms/user-auth-form.tsx @@ -1,25 +1,25 @@ -"use client" +"use client"; -import * as React from "react" -import { useSearchParams } from "next/navigation" -import { zodResolver } from "@hookform/resolvers/zod" -import { signIn } from "next-auth/react" -import { useForm } from "react-hook-form" -import * as z from "zod" +import * as React from "react"; +import { useSearchParams } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; -import { cn } from "@/lib/utils" -import { userAuthSchema } from "@/lib/validations/auth" -import { buttonVariants } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { toast } from "@/components/ui/use-toast" -import { Icons } from "@/components/shared/icons" +import { cn } from "@/lib/utils"; +import { userAuthSchema } from "@/lib/validations/auth"; +import { buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Icons } from "@/components/shared/icons"; interface UserAuthFormProps extends React.HTMLAttributes { - type?: string + type?: string; } -type FormData = z.infer +type FormData = z.infer; export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { const { @@ -28,35 +28,31 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { formState: { errors }, } = useForm({ resolver: zodResolver(userAuthSchema), - }) - const [isLoading, setIsLoading] = React.useState(false) - const [isGoogleLoading, setIsGoogleLoading] = React.useState(false) - const searchParams = useSearchParams() + }); + const [isLoading, setIsLoading] = React.useState(false); + const [isGoogleLoading, setIsGoogleLoading] = React.useState(false); + const searchParams = useSearchParams(); async function onSubmit(data: FormData) { - setIsLoading(true) + setIsLoading(true); - const signInResult = await signIn("email", { + const signInResult = await signIn("resend", { email: data.email.toLowerCase(), redirect: false, callbackUrl: searchParams?.get("from") || "/dashboard", - }) + }); - setIsLoading(false) + setIsLoading(false); - // TODO: replace shadcn toast by react-hot-toast if (!signInResult?.ok) { - return toast({ - title: "Something went wrong.", - description: "Your sign in request failed. Please try again.", - variant: "destructive", - }) + return toast.error("Something went wrong.", { + description: "Your sign in request failed. Please try again." + }); } - return toast({ - title: "Check your email", + return toast.success("Check your email", { description: "We sent you a login link. Be sure to check your spam too.", - }) + }); } return ( @@ -87,9 +83,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { {isLoading && ( )} - {type === "register" - ? "Sign Up with Email" - : "Sign In with Email"} + {type === "register" ? "Sign Up with Email" : "Sign In with Email"}
@@ -107,8 +101,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { type="button" className={cn(buttonVariants({ variant: "outline" }))} onClick={() => { - setIsGoogleLoading(true) - signIn("google") + setIsGoogleLoading(true); + signIn("google"); }} disabled={isLoading || isGoogleLoading} > @@ -120,5 +114,5 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { Google
- ) + ); } diff --git a/components/forms/user-name-form.tsx b/components/forms/user-name-form.tsx index 405f7425..1a57b79c 100644 --- a/components/forms/user-name-form.tsx +++ b/components/forms/user-name-form.tsx @@ -4,6 +4,7 @@ import { useState, useTransition } from "react"; import { updateUserName, type FormData } from "@/actions/update-user-name"; import { zodResolver } from "@hookform/resolvers/zod"; import { User } from "@prisma/client"; +import { useSession } from "next-auth/react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -26,16 +27,13 @@ interface UserNameFormProps { } export function UserNameForm({ user }: UserNameFormProps) { + const { update } = useSession(); const [updated, setUpdated] = useState(false); const [isPending, startTransition] = useTransition(); const updateUserNameWithId = updateUserName.bind(null, user.id); const checkUpdate = (value) => { - if (user.name !== value) { - setUpdated(true); - } else { - setUpdated(false); - } + setUpdated(user.name !== value); }; const { @@ -58,6 +56,7 @@ export function UserNameForm({ user }: UserNameFormProps) { description: "Your name was not updated. Please try again.", }); } else { + await update(); setUpdated(false); toast.success("Your name has been updated."); } diff --git a/components/forms/user-role-form.tsx b/components/forms/user-role-form.tsx new file mode 100644 index 00000000..06c63507 --- /dev/null +++ b/components/forms/user-role-form.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { updateUserRole, type FormData } from "@/actions/update-user-role"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { User, UserRole } from "@prisma/client"; +import { useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { userRoleSchema } from "@/lib/validations/user"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Icons } from "@/components/shared/icons"; + +interface UserNameFormProps { + user: Pick; +} + +export function UserRoleForm({ user }: UserNameFormProps) { + const { update } = useSession(); + const [updated, setUpdated] = useState(false); + const [isPending, startTransition] = useTransition(); + const updateUserRoleWithId = updateUserRole.bind(null, user.id); + + const roles = Object.values(UserRole); + const [role, setRole] = useState(user.role); + + const form = useForm({ + resolver: zodResolver(userRoleSchema), + values: { + role: role, + }, + }); + + const onSubmit = (data: z.infer) => { + startTransition(async () => { + const { status } = await updateUserRoleWithId(data); + + if (status !== "success") { + toast.error("Something went wrong.", { + description: "Your role was not updated. Please try again.", + }); + } else { + await update(); + setUpdated(false); + toast.success("Your role has been updated."); + } + }); + }; + + return ( +
+ + + + Your Role + + Select the role what you want for test the app. + + + + ( + + Role + + + + )} + /> + + +

+ Remove this card on real production. +

+ +
+
+
+ + ); +} diff --git a/components/layout/dashboard-sidenav.tsx b/components/layout/dashboard-sidenav.tsx index e9eca298..a769427d 100644 --- a/components/layout/dashboard-sidenav.tsx +++ b/components/layout/dashboard-sidenav.tsx @@ -19,7 +19,7 @@ export function DashboardNav({ items }: DashboardNavProps) { } return ( -