feat(api): authentication
This commit is contained in:
@@ -1,19 +1,9 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react";
|
||||
|
||||
import { LoginForm } from "#dashboard/components/forms/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<a className="flex items-center gap-2 font-medium" href="#">
|
||||
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
Acme Inc.
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<LoginForm />
|
||||
@@ -24,7 +14,7 @@ export default function LoginPage() {
|
||||
<img
|
||||
alt="verification placeholder"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
src="/placeholder.svg"
|
||||
src="https://images.pexels.com/photos/30690932/pexels-photo-30690932.jpeg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AppRouter } from "@basango/api/trpc/routers/_app";
|
||||
import { DEFAULT_REFRESH_TOKEN_COOKIE } from "@basango/domain/constants";
|
||||
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
const client = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
transformer: superjson,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3080"}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken =
|
||||
cookieStore.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value ??
|
||||
(await getRefreshTokenFromBody(request));
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: "Missing refresh token" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await client.auth.refresh.mutate({
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
return NextResponse.json(tokens);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid refresh token" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
async function getRefreshTokenFromBody(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (typeof body?.refreshToken === "string") {
|
||||
return body.refreshToken;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed bodies
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,51 +1,136 @@
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
"use client";
|
||||
|
||||
import { loginSchema } from "@basango/domain/models";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@basango/ui/components/field";
|
||||
import { Input } from "@basango/ui/components/input";
|
||||
import { SubmitButton } from "@basango/ui/components/submit-button";
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useZodForm } from "#dashboard/hooks/use-zod-form";
|
||||
import { useUserStore } from "#dashboard/stores/user-store";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
import { persistSessionTokens } from "#dashboard/utils/auth/client";
|
||||
|
||||
type LoginValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm({ className, ...props }: React.ComponentProps<"form">) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const trpc = useTRPC();
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const locale = params?.locale ?? "en";
|
||||
|
||||
const form = useZodForm(loginSchema, {
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.auth.login.mutationOptions({
|
||||
onError(error) {
|
||||
toast.error(error.message ?? "Unable to login. Try again.");
|
||||
},
|
||||
async onSuccess(data) {
|
||||
persistSessionTokens({
|
||||
accessToken: data.accessToken,
|
||||
accessTokenExpiresAt: data.accessTokenExpiresAt,
|
||||
refreshToken: data.refreshToken,
|
||||
refreshTokenExpiresAt: data.refreshTokenExpiresAt,
|
||||
});
|
||||
setUser(data.user);
|
||||
|
||||
form.reset();
|
||||
router.push(searchParams?.get("return_to") ?? `/${locale}/dashboard`);
|
||||
router.refresh();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: LoginValues) => {
|
||||
mutation.mutate(values);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<form
|
||||
className={cn("flex flex-col gap-6", className)}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
{...props}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<h1 className="text-2xl font-bold">Basango Dashboard</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" placeholder="m@example.com" required type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a className="ml-auto text-sm underline-offset-4 hover:underline" href="#">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" required type="password" />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">Login</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or continue with</FieldSeparator>
|
||||
<Field>
|
||||
<Button type="button" variant="outline">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="email"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="m@example.com"
|
||||
type="email"
|
||||
/>
|
||||
</svg>
|
||||
Login with GitHub
|
||||
</Button>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
|
||||
<a className="ml-auto text-sm underline-offset-4 hover:underline" href="#">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="current-password"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
type="password"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SubmitButton className="w-full" isSubmitting={mutation.isPending} type="submit">
|
||||
Login
|
||||
</SubmitButton>
|
||||
|
||||
<Field>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account?{" "}
|
||||
<a className="underline underline-offset-4" href="#">
|
||||
|
||||
@@ -1,54 +1,18 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@basango/ui/components/breadcrumb";
|
||||
import { Separator } from "@basango/ui/components/separator";
|
||||
//import { LanguageSelector, ThemeSelector } from "@/components/ui/shared/settings";
|
||||
import { SidebarTrigger } from "@basango/ui/components/sidebar";
|
||||
|
||||
import { ThemeToggle } from "#dashboard/components/theme-toggle";
|
||||
|
||||
export function PageHeader() {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<header className="border-b flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// <header className="border-b flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
// <div className="flex items-center gap-2 px-4">
|
||||
// <SidebarTrigger className="-ml-1" />
|
||||
// <Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
|
||||
// <Breadcrumb>
|
||||
// <BreadcrumbList>
|
||||
// <BreadcrumbItem className="hidden md:block">
|
||||
// <Button className="cursor-pointer" onClick={() => navigate(-1)} variant="ghost">
|
||||
// <ArrowLeftIcon />
|
||||
// <span>{t("ui.shared.shell.page_header.go_back")}</span>
|
||||
// </Button>
|
||||
// </BreadcrumbItem>
|
||||
// </BreadcrumbList>
|
||||
// </Breadcrumb>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-2 px-4">
|
||||
// <LanguageSelector />
|
||||
// <ThemeSelector />
|
||||
// </div>
|
||||
// </header>
|
||||
|
||||
@@ -12,11 +12,8 @@ export function AppSidebarInfo() {
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
size="lg"
|
||||
>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<img alt="Logo" className="size-8 rounded-lg object-cover" src="/logo.svg" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">Basango</span>
|
||||
<span className="truncate font-medium">Basango Dashboard</span>
|
||||
<span className="truncate text-xs">v{version}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Avatar, AvatarFallback } from "@basango/ui/components/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
@@ -16,10 +15,24 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from "lucide-react";
|
||||
import { ChevronsUpDown, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useUser } from "#dashboard/hooks/use-user";
|
||||
import { clearSessionTokens } from "#dashboard/utils/auth/client";
|
||||
import { getInitials } from "#dashboard/utils/utils";
|
||||
|
||||
export function AppSidebarUser() {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
const { user, setUser } = useUser();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSessionTokens();
|
||||
setUser(null);
|
||||
router.push(`/login`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -31,11 +44,13 @@ export function AppSidebarUser() {
|
||||
size="lg"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg">BN</AvatarFallback>
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials(user?.name ?? user?.email ?? "")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">Bernard Ng</span>
|
||||
<span className="truncate text-xs">bernard.ng@example.com</span>
|
||||
<span className="truncate font-medium">{user?.name ?? user?.email ?? ""}</span>
|
||||
<span className="truncate text-xs">{user?.email ?? ""}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
@@ -49,38 +64,18 @@ export function AppSidebarUser() {
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg">BN</AvatarFallback>
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials(user?.name ?? user?.email ?? "")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">Bernard Ng</span>
|
||||
<span className="truncate text-xs">bernard.ng@example.com</span>
|
||||
<span className="truncate font-medium">{user?.name ?? user?.email ?? ""}</span>
|
||||
<span className="truncate text-xs">{user?.email ?? ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@basango/ui/components/dropdown-menu";
|
||||
import { Laptop, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// To avoid a hydration error caused by mismatched server/client rendering,
|
||||
// we wait for the component to mount before using `theme` from `next-themes`,
|
||||
// since it relies on localStorage and is not available during SSR.s
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="h-8 w-8 shrink-0" size="icon" variant="ghost">
|
||||
{theme === "light" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : theme === "dark" ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Laptop className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useUserStore } from "#dashboard/stores/user-store";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
|
||||
export function useUser() {
|
||||
const trpc = useTRPC();
|
||||
const { user, setUser } = useUserStore();
|
||||
|
||||
const queryOptions = trpc.auth.session.queryOptions();
|
||||
const query = useQuery({
|
||||
...queryOptions,
|
||||
enabled: queryOptions.enabled ?? !user,
|
||||
staleTime: queryOptions.staleTime ?? 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setUser(query.data);
|
||||
} else if (query.isError) {
|
||||
setUser(null);
|
||||
}
|
||||
}, [query.data, query.isError, setUser]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
setUser,
|
||||
user,
|
||||
};
|
||||
}
|
||||
+109
-32
@@ -1,50 +1,127 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import {
|
||||
DEFAULT_ACCESS_TOKEN_COOKIE,
|
||||
DEFAULT_REFRESH_TOKEN_COOKIE,
|
||||
} from "@basango/domain/constants";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createI18nMiddleware } from "next-international/middleware";
|
||||
|
||||
const SUPPORTED_LOCALES = ["en"] as const;
|
||||
const DEFAULT_LOCALE = SUPPORTED_LOCALES[0];
|
||||
|
||||
const I18nMiddleware = createI18nMiddleware({
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
locales: SUPPORTED_LOCALES as unknown as string[],
|
||||
urlMappingStrategy: "rewrite",
|
||||
});
|
||||
|
||||
const PUBLIC_PATHS = new Set(["/login"]);
|
||||
|
||||
type SessionTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
};
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const response = await I18nMiddleware(request);
|
||||
const { locale, pathname } = extractLocaleAndPath(request);
|
||||
let accessToken = request.cookies.get(DEFAULT_ACCESS_TOKEN_COOKIE)?.value;
|
||||
const refreshToken = request.cookies.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value;
|
||||
const isPublicRoute = PUBLIC_PATHS.has(pathname);
|
||||
let refreshedTokens: SessionTokens | null = null;
|
||||
|
||||
// const nextUrl = request.nextUrl;
|
||||
if (!accessToken && refreshToken) {
|
||||
refreshedTokens = await refreshSession(request);
|
||||
accessToken = refreshedTokens?.accessToken;
|
||||
}
|
||||
|
||||
// const pathnameLocale = nextUrl.pathname.split("/", 2)?.[1];
|
||||
if (!isPublicRoute && !accessToken) {
|
||||
return redirectToLogin(request, locale);
|
||||
}
|
||||
|
||||
// // Remove the locale from the pathname
|
||||
// const pathnameWithoutLocale = pathnameLocale
|
||||
// ? nextUrl.pathname.slice(pathnameLocale.length + 1)
|
||||
// : nextUrl.pathname;
|
||||
if (accessToken && pathname === "/login") {
|
||||
const redirectUrl = new URL(`/${locale}/dashboard`, request.url);
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// // Create a new URL without the locale in the pathname
|
||||
// const newUrl = new URL(pathnameWithoutLocale || "/", request.url);
|
||||
// const encodedSearchParams = `${newUrl?.pathname?.substring(1)}${newUrl.search}`;
|
||||
// const session = request.cookies.get("token")?.value;
|
||||
const i18nResponse = await I18nMiddleware(request);
|
||||
|
||||
// // 1. Not authenticated
|
||||
// if (
|
||||
// !session &&
|
||||
// newUrl.pathname !== "/login" &&
|
||||
// !newUrl.pathname.includes("/i/") &&
|
||||
// !newUrl.pathname.includes("/s/") &&
|
||||
// !newUrl.pathname.includes("/verify")
|
||||
// ) {
|
||||
// const url = new URL("/login", request.url);
|
||||
if (refreshedTokens) {
|
||||
setSessionCookies(i18nResponse, refreshedTokens, request);
|
||||
}
|
||||
|
||||
// if (encodedSearchParams) {
|
||||
// url.searchParams.append("return_to", encodedSearchParams);
|
||||
// }
|
||||
|
||||
// return NextResponse.redirect(url);
|
||||
// }
|
||||
|
||||
// If all checks pass, return the original or updated response
|
||||
return response;
|
||||
return i18nResponse;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
||||
};
|
||||
|
||||
function extractLocaleAndPath(request: NextRequest) {
|
||||
const segments = request.nextUrl.pathname.split("/").filter(Boolean);
|
||||
const maybeLocale = segments[0];
|
||||
const localeFromPath =
|
||||
maybeLocale && SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale === maybeLocale);
|
||||
const locale = localeFromPath ?? DEFAULT_LOCALE;
|
||||
const pathSegments = localeFromPath ? segments.slice(1) : segments;
|
||||
const pathname = `/${pathSegments.join("/")}`.replace(/\/+/g, "/") || "/";
|
||||
|
||||
return { locale, pathname };
|
||||
}
|
||||
|
||||
function redirectToLogin(request: NextRequest, locale: string) {
|
||||
const target = new URL(`/${locale}/login`, request.url);
|
||||
const returnTo = buildReturnToParam(request);
|
||||
|
||||
if (returnTo) {
|
||||
target.searchParams.set("return_to", returnTo);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(target);
|
||||
}
|
||||
|
||||
function buildReturnToParam(request: NextRequest) {
|
||||
const path = `${request.nextUrl.pathname}${request.nextUrl.search}`;
|
||||
return path !== "/" ? path : null;
|
||||
}
|
||||
|
||||
async function refreshSession(request: NextRequest): Promise<SessionTokens | null> {
|
||||
try {
|
||||
const response = await fetch(new URL("/api/session/refresh", request.url), {
|
||||
headers: {
|
||||
cookie: request.headers.get("cookie") ?? "",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as SessionTokens;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionCookies(response: NextResponse, tokens: SessionTokens, request: NextRequest) {
|
||||
const secure = request.nextUrl.protocol === "https:";
|
||||
|
||||
response.cookies.set({
|
||||
expires: new Date(tokens.accessTokenExpiresAt),
|
||||
name: DEFAULT_ACCESS_TOKEN_COOKIE,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
value: tokens.accessToken,
|
||||
});
|
||||
|
||||
response.cookies.set({
|
||||
expires: new Date(tokens.refreshTokenExpiresAt),
|
||||
name: DEFAULT_REFRESH_TOKEN_COOKIE,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
value: tokens.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
type SessionUser = RouterOutputs["auth"]["session"];
|
||||
|
||||
type UserState = {
|
||||
user: SessionUser | null;
|
||||
setUser: (user: SessionUser | null) => void;
|
||||
};
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
setUser: (user) => set({ user }),
|
||||
user: null,
|
||||
}),
|
||||
{
|
||||
name: "basango/user",
|
||||
partialize: (state) => ({ user: state.user }),
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -8,6 +8,8 @@ import { createTRPCContext } from "@trpc/tanstack-react-query";
|
||||
import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { getClientAccessToken } from "#dashboard/utils/auth/client";
|
||||
|
||||
import { makeQueryClient } from "./query-client";
|
||||
|
||||
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
|
||||
@@ -44,11 +46,12 @@ export function TRPCReactProvider(
|
||||
links: [
|
||||
httpBatchLink({
|
||||
headers: async () => {
|
||||
//const token = window.localStorage.getItem("auth_token");
|
||||
|
||||
return {
|
||||
//Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const token = getClientAccessToken();
|
||||
return token
|
||||
? {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
: {};
|
||||
},
|
||||
transformer: superjson,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { cache } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { getServerAccessToken } from "#dashboard/utils/auth/server";
|
||||
|
||||
import { makeQueryClient } from "./query-client";
|
||||
|
||||
// IMPORTANT: Create a stable getter for the query client that
|
||||
@@ -23,14 +25,16 @@ export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
async headers() {
|
||||
//const token = window.localStorage.getItem("auth_token");
|
||||
const token = await getServerAccessToken();
|
||||
|
||||
return {
|
||||
//Authorization: `Bearer ${token}`,
|
||||
// "x-user-country": await getCountryCode(),
|
||||
// "x-user-locale": await getLocale(),
|
||||
// "x-user-timezone": await getTimezone(),
|
||||
};
|
||||
return token
|
||||
? {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// "x-user-country": await getCountryCode(),
|
||||
// "x-user-locale": await getLocale(),
|
||||
// "x-user-timezone": await getTimezone(),
|
||||
}
|
||||
: {};
|
||||
},
|
||||
transformer: superjson,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DEFAULT_ACCESS_TOKEN_COOKIE,
|
||||
DEFAULT_REFRESH_TOKEN_COOKIE,
|
||||
} from "@basango/domain/constants";
|
||||
|
||||
type PersistTokensParams = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresAt: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
};
|
||||
|
||||
export function getClientAccessToken() {
|
||||
return readCookie(DEFAULT_ACCESS_TOKEN_COOKIE);
|
||||
}
|
||||
|
||||
export function getClientRefreshToken() {
|
||||
return readCookie(DEFAULT_REFRESH_TOKEN_COOKIE);
|
||||
}
|
||||
|
||||
export function persistSessionTokens(tokens: PersistTokensParams) {
|
||||
setCookie(DEFAULT_ACCESS_TOKEN_COOKIE, tokens.accessToken, tokens.accessTokenExpiresAt);
|
||||
setCookie(DEFAULT_REFRESH_TOKEN_COOKIE, tokens.refreshToken, tokens.refreshTokenExpiresAt);
|
||||
}
|
||||
|
||||
export function clearSessionTokens() {
|
||||
deleteCookie(DEFAULT_ACCESS_TOKEN_COOKIE);
|
||||
deleteCookie(DEFAULT_REFRESH_TOKEN_COOKIE);
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
|
||||
const cookie = cookies.find((item) => item.startsWith(`${name}=`));
|
||||
return cookie ? decodeURIComponent(cookie.slice(name.length + 1)) : undefined;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, expiresAt: string) {
|
||||
const expires = new Date(expiresAt).toUTCString();
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
const encodedValue = encodeURIComponent(value);
|
||||
document.cookie = `${name}=${encodedValue}; Expires=${expires}; Path=/; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
function deleteCookie(name: string) {
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax${secure}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { DEFAULT_ACCESS_TOKEN_COOKIE, DEFAULT_REFRESH_TOKEN_COOKIE } from "#domain/constants";
|
||||
|
||||
export async function getServerAccessToken() {
|
||||
const cookiesStore = await cookies();
|
||||
return cookiesStore.get(DEFAULT_ACCESS_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
|
||||
export async function getServerRefreshToken() {
|
||||
const cookiesStore = await cookies();
|
||||
return cookiesStore.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function formatSize(bytes: number): string {
|
||||
}).format(+Math.round(bytes / 1024 ** unitIndex));
|
||||
}
|
||||
|
||||
export function secondsToHoursAndMinutes(seconds: number) {
|
||||
export function formatHoursMinutes(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user