Skip to content

Commit f594a39

Browse files
committed
finished implementation
1 parent add71c3 commit f594a39

17 files changed

+316
-67
lines changed

app/components/app-sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
170170
<NavSecondary items={data.navSecondary} className="mt-auto" />
171171
</SidebarContent>
172172
<SidebarFooter>
173-
<NavUser user={data.user} />
173+
<NavUser />
174174
</SidebarFooter>
175175
</Sidebar>
176176
)

app/components/nav-user.tsx

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { IconCreditCard, IconDotsVertical, IconLogout, IconNotification, IconUserCircle } from "@tabler/icons-react"
22

3-
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
43
import {
54
DropdownMenu,
65
DropdownMenuContent,
@@ -11,18 +10,11 @@ import {
1110
DropdownMenuTrigger,
1211
} from "~/components/ui/dropdown-menu"
1312
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/components/ui/sidebar"
13+
import { useDashboardData } from "~/hooks/use-dashboard-data"
1414

15-
export function NavUser({
16-
user,
17-
}: {
18-
user: {
19-
name: string
20-
email: string
21-
avatar: string
22-
}
23-
}) {
15+
export function NavUser() {
2416
const { isMobile } = useSidebar()
25-
17+
const { user } = useDashboardData()
2618
return (
2719
<SidebarMenu>
2820
<SidebarMenuItem>
@@ -32,12 +24,7 @@ export function NavUser({
3224
size="lg"
3325
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
3426
>
35-
<Avatar className="h-8 w-8 rounded-lg grayscale">
36-
<AvatarImage src={user.avatar} alt={user.name} />
37-
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
38-
</Avatar>
3927
<div className="grid flex-1 text-left text-sm leading-tight">
40-
<span className="truncate font-medium">{user.name}</span>
4128
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
4229
</div>
4330
<IconDotsVertical className="ml-auto size-4" />
@@ -51,12 +38,7 @@ export function NavUser({
5138
>
5239
<DropdownMenuLabel className="p-0 font-normal">
5340
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
54-
<Avatar className="h-8 w-8 rounded-lg">
55-
<AvatarImage src={user.avatar} alt={user.name} />
56-
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
57-
</Avatar>
5841
<div className="grid flex-1 text-left text-sm leading-tight">
59-
<span className="truncate font-medium">{user.name}</span>
6042
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
6143
</div>
6244
</div>

app/components/site-header.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Form, href } from "react-router"
12
import { Button } from "~/components/ui/button"
23
import { Separator } from "~/components/ui/separator"
34
import { SidebarTrigger } from "~/components/ui/sidebar"
@@ -10,16 +11,11 @@ export function SiteHeader() {
1011
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
1112
<h1 className="font-medium text-base">Documents</h1>
1213
<div className="ml-auto flex items-center gap-2">
13-
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
14-
<a
15-
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
16-
rel="noopener noreferrer"
17-
target="_blank"
18-
className="dark:text-foreground"
19-
>
20-
GitHub
21-
</a>
22-
</Button>
14+
<Form method="POST" action={href("/logout")}>
15+
<Button variant="ghost" size="sm" className="hidden sm:flex">
16+
Logout
17+
</Button>
18+
</Form>
2319
</div>
2420
</div>
2521
</header>

app/domain/auth/auth.server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createCookieSessionStorage, href, redirect, type unstable_MiddlewareFunction } from "react-router"
2+
import { unstable_createSessionMiddleware as sessionMiddleware } from "remix-utils/middleware/session"
3+
4+
const authSessionStorage = createCookieSessionStorage({
5+
cookie: {
6+
name: "session",
7+
httpOnly: true,
8+
sameSite: "lax",
9+
path: "/",
10+
maxAge: 60 * 60 * 24 * 7, // 1 week
11+
},
12+
})
13+
14+
const [authSessionMiddleware, getAuthSessionFromContext] = sessionMiddleware(authSessionStorage)
15+
16+
export { authSessionMiddleware, getAuthSessionFromContext }
17+
18+
export const requireUser: unstable_MiddlewareFunction = ({ context }, next) => {
19+
const authSession = getAuthSessionFromContext(context)
20+
const session = authSession.get("user")
21+
if (!session) {
22+
throw redirect(href("/login"))
23+
}
24+
return next()
25+
}

app/domain/auth/login.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import z from "zod/v4"
2+
3+
export const loginFormSchema = z.object({
4+
email: z.email(),
5+
password: z.string().min(8).max(100),
6+
redirectTo: z.string().optional(),
7+
})
8+
9+
export type UserLoginData = z.infer<typeof loginFormSchema>

app/domain/auth/register.schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import z from "zod/v4"
2+
3+
export const registerFormSchema = z
4+
.object({
5+
email: z.email(),
6+
password: z.string().min(8).max(100),
7+
confirmPassword: z.string().min(8).max(100),
8+
})
9+
.refine((data) => data.password === data.confirmPassword, {
10+
message: "Passwords don't match",
11+
path: ["confirmPassword"],
12+
})
13+
14+
export type NewUserData = z.infer<typeof registerFormSchema>

app/domain/auth/user.server.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,95 @@
1-
export async function registerUser() {
2-
return null
1+
import { hash } from "node:crypto"
2+
import { getAuthSession } from "@domain/utils/global-context"
3+
import { db } from "~/db/db.server"
4+
import type { UserLoginData } from "./login.schema"
5+
import type { NewUserData } from "./register.schema"
6+
7+
export async function getUserByEmail(email: string) {
8+
const user = await db.user.findUnique({
9+
where: { email },
10+
select: {
11+
id: true,
12+
email: true,
13+
},
14+
})
15+
return user
316
}
417

5-
export async function loginUser() {
6-
return null
18+
export async function registerUser(userData: NewUserData) {
19+
const existingUser = await getUserByEmail(userData.email)
20+
// User already exists, return an error
21+
if (existingUser) {
22+
return {
23+
errors: {
24+
email: {
25+
message: "Email already exists",
26+
type: "custom",
27+
},
28+
},
29+
}
30+
}
31+
32+
// Create a new user
33+
const newUser = await db.user.create({
34+
data: {
35+
email: userData.email,
36+
password: {
37+
create: {
38+
hash: hash("sha256", userData.password),
39+
},
40+
},
41+
},
42+
})
43+
44+
const authSession = getAuthSession()
45+
authSession.set("user", {
46+
id: newUser.id,
47+
email: newUser.email,
48+
})
49+
return {
50+
errors: null,
51+
}
52+
}
53+
54+
export async function loginUser(loginData: UserLoginData) {
55+
const userExists = await getUserByEmail(loginData.email)
56+
if (!userExists) {
57+
return {
58+
errors: {
59+
email: {
60+
message: "This email does not exist",
61+
},
62+
},
63+
}
64+
}
65+
66+
const loggedInUser = await db.user.findUnique({
67+
where: {
68+
email: loginData.email,
69+
password: {
70+
hash: hash("sha256", loginData.password),
71+
},
72+
},
73+
})
74+
75+
if (!loggedInUser) {
76+
return {
77+
errors: {
78+
email: {
79+
message: "This password is incorrect",
80+
},
81+
},
82+
}
83+
}
84+
85+
const authSession = getAuthSession()
86+
authSession.set("user", {
87+
email: loggedInUser.email,
88+
id: loggedInUser.id,
89+
})
90+
return {
91+
errors: null,
92+
}
793
}
894

995
export async function signoutUser() {

app/domain/utils/global-context.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { AsyncLocalStorage } from "node:async_hooks"
2+
import { getAuthSessionFromContext } from "@domain/auth/auth.server"
3+
import { getUserByEmail } from "@domain/auth/user.server"
4+
import type { User } from "@prisma-generated/client"
5+
import type { Session, unstable_MiddlewareFunction } from "react-router"
6+
7+
type GlobalStorage = {
8+
authSession: Session
9+
user: Pick<User, "email" | "id"> | null
10+
}
11+
12+
const globalStorage = new AsyncLocalStorage<GlobalStorage>()
13+
14+
const getGlobalStorage = () => {
15+
const storage = globalStorage.getStore()
16+
17+
if (!storage) {
18+
throw new Error("Storage unavailable")
19+
}
20+
21+
return storage
22+
}
23+
24+
export const getAuthSession = () => {
25+
const storage = getGlobalStorage()
26+
return storage.authSession
27+
}
28+
29+
export const getOptionalUser = () => {
30+
const storage = getGlobalStorage()
31+
return storage.user
32+
}
33+
34+
export const getUser = () => {
35+
const user = getOptionalUser()
36+
if (!user) {
37+
throw new Error("User should be available here")
38+
}
39+
return user
40+
}
41+
42+
export const globalStorageMiddleware: unstable_MiddlewareFunction = async ({ context }, next) => {
43+
const authSession = getAuthSessionFromContext(context)
44+
const userData = authSession.get("user")
45+
const user = userData?.email ? await getUserByEmail(userData.email) : null
46+
return new Promise((resolve) => {
47+
globalStorage.run(
48+
{
49+
authSession,
50+
user,
51+
},
52+
() => {
53+
resolve(next())
54+
}
55+
)
56+
})
57+
}

app/hooks/use-dashboard-data.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useRouteLoaderData } from "react-router"
2+
import type { Route } from "../routes/+types/dashboard"
3+
4+
export function useDashboardData() {
5+
const match = useRouteLoaderData<Route.ComponentProps["loaderData"]>("routes/dashboard")
6+
if (!match) {
7+
throw new Error("this dashboard data does not exist on the current route")
8+
}
9+
return match
10+
}

app/root.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { authSessionMiddleware } from "@domain/auth/auth.server"
2+
import { getOptionalUser, globalStorageMiddleware } from "@domain/utils/global-context"
13
import { useTranslation } from "react-i18next"
2-
import type { LinksFunction } from "react-router"
4+
import type { LinksFunction, unstable_MiddlewareFunction } from "react-router"
35
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from "react-router"
46
import { useChangeLanguage } from "remix-i18next/react"
57
import type { Route } from "./+types/root"
@@ -9,8 +11,9 @@ import tailwindcss from "./tailwind.css?url"
911

1012
export async function loader({ context, request }: Route.LoaderArgs) {
1113
const { lang, clientEnv } = context.get(globalAppContext)
14+
const user = getOptionalUser()
1215
const hints = getHints(request)
13-
return { lang, clientEnv, hints }
16+
return { lang, clientEnv, hints, user }
1417
}
1518

1619
export const links: LinksFunction = () => [{ rel: "stylesheet", href: tailwindcss }]
@@ -84,3 +87,5 @@ export const ErrorBoundary = () => {
8487
</div>
8588
)
8689
}
90+
// @ts-expect-error
91+
export const unstable_middleware: unstable_MiddlewareFunction[] = [authSessionMiddleware, globalStorageMiddleware]

0 commit comments

Comments
 (0)