diff --git a/AGENTS.md b/AGENTS.md index 7dca36a..a71ff35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Conventions - Prefer named exports in libraries. Avoid barrel files unless necessary. - Use `workspace:*` for internal dependencies; do not hardcode versions. - Keep changes minimal and localized; avoid cross-cutting refactors without discussion. +- When using tRPC in React, always compose `useQuery`/`useMutation` from TanStack with `trpc.*.queryOptions`/`mutationOptions` instead of calling `trpc.*.useQuery`/`useMutation` helpers directly (they are deprecated). Tasks & Commands - Install: `bun install` (run at repo root only). @@ -67,4 +68,3 @@ Gotchas Contact Points - Architecture overview: `docs/architecture.md`. - Forms handling patterns: `docs/forms-handling.md`. - diff --git a/apps/api/src/trpc/init.ts b/apps/api/src/trpc/init.ts index 61a7b44..20e6ad5 100644 --- a/apps/api/src/trpc/init.ts +++ b/apps/api/src/trpc/init.ts @@ -1,11 +1,11 @@ import { Database, db } from "@basango/db/client"; -import { initTRPC } from "@trpc/server"; +import { TRPCError, initTRPC } from "@trpc/server"; import type { Context } from "hono"; import superjson from "superjson"; import { withAuthentication } from "#api/trpc/middlewares/auth"; import { withDatabase } from "#api/trpc/middlewares/db"; -import { Session, verifyAccessToken } from "#api/utils/auth"; +import { Session, getSession } from "#api/utils/auth"; import { getGeoContext } from "#api/utils/geo"; type TRPCContext = { @@ -16,7 +16,7 @@ type TRPCContext = { export const createTRPCContext = async (_: unknown, c: Context): Promise => { const accessToken = c.req.header("Authorization")?.split(" ")[1]; - const session = await verifyAccessToken(accessToken); + const session = await getSession(db, accessToken); const geo = getGeoContext(c.req); return { @@ -51,13 +51,13 @@ export const publicProcedure = t.procedure.use(withDatabaseMiddleware); export const protectedProcedure = t.procedure .use(withDatabaseMiddleware) - .use(withAutenticationMiddleware) // NOTE: This is needed to ensure that the teamId is set in the context + .use(withAutenticationMiddleware) .use(async (opts) => { const { session } = opts.ctx; - // if (!session) { - // throw new TRPCError({ code: "UNAUTHORIZED" }); - // } + if (!session) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } return opts.next({ ctx: { diff --git a/apps/api/src/trpc/middlewares/auth.ts b/apps/api/src/trpc/middlewares/auth.ts index bcb3187..0b1f354 100644 --- a/apps/api/src/trpc/middlewares/auth.ts +++ b/apps/api/src/trpc/middlewares/auth.ts @@ -1,6 +1,5 @@ import type { Database } from "@basango/db/client"; - -// import { TRPCError } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; import type { Session } from "#api/utils/auth"; @@ -18,14 +17,12 @@ export const withAuthentication = async (opts: { }) => { const { ctx, next } = opts; - // const userId = ctx.session?.user?.id; - - // if (!userId) { - // throw new TRPCError({ - // code: "UNAUTHORIZED", - // message: "No permission to access", - // }); - // } + if (!ctx.session) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication is required to access this resource.", + }); + } return next({ ctx: { diff --git a/apps/api/src/trpc/routers/_app.ts b/apps/api/src/trpc/routers/_app.ts index d65a41e..a6e97d4 100644 --- a/apps/api/src/trpc/routers/_app.ts +++ b/apps/api/src/trpc/routers/_app.ts @@ -2,10 +2,12 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { createTRPCRouter } from "#api/trpc/init"; import { articlesRouter } from "#api/trpc/routers/articles"; +import { authRouter } from "#api/trpc/routers/auth"; import { sourcesRouter } from "#api/trpc/routers/sources"; export const appRouter = createTRPCRouter({ articles: articlesRouter, + auth: authRouter, sources: sourcesRouter, }); diff --git a/apps/api/src/trpc/routers/auth.ts b/apps/api/src/trpc/routers/auth.ts new file mode 100644 index 0000000..e92646b --- /dev/null +++ b/apps/api/src/trpc/routers/auth.ts @@ -0,0 +1,86 @@ +import { getUserByEmail, getUserById } from "@basango/db/queries"; +import { loginSchema, refreshSessionSchema } from "@basango/domain/models"; +import { verifyPassword } from "@basango/encryption"; +import { TRPCError } from "@trpc/server"; + +import { createTRPCRouter, protectedProcedure, publicProcedure } from "#api/trpc/init"; +import { createSessionTokens, verifyRefreshToken } from "#api/utils/auth"; + +export const authRouter = createTRPCRouter({ + login: publicProcedure.input(loginSchema).mutation(async ({ ctx, input }) => { + const user = await getUserByEmail(ctx.db, input.email); + + if (!user || user.isLocked) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid credentials.", + }); + } + + const isValidPassword = await verifyPassword(input.password, user.password); + + if (!isValidPassword) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid credentials.", + }); + } + + const session = { + user: { + email: user.email, + id: user.id, + name: user.name, + }, + }; + + const tokens = await createSessionTokens(session); + + return { + ...tokens, + user: session.user, + }; + }), + + refresh: publicProcedure.input(refreshSessionSchema).mutation(async ({ ctx, input }) => { + const session = await verifyRefreshToken(input.refreshToken); + + if (!session) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid refresh token.", + }); + } + + const user = await getUserById(ctx.db, { + email: session.user.email, + id: session.user.id, + }); + + if (!user || user.isLocked) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid refresh token.", + }); + } + + const tokens = await createSessionTokens({ + user: { + email: user.email, + id: user.id, + name: user.name, + }, + }); + + return { + ...tokens, + user: { + email: user.email, + id: user.id, + name: user.name, + }, + }; + }), + + session: protectedProcedure.query(({ ctx }) => ctx.session.user), +}); diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index d55c6a8..3474bcb 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -1,4 +1,12 @@ -import { type JWTPayload, jwtVerify } from "jose"; +import { Database } from "@basango/db/client"; +import { getUserById } from "@basango/db/queries"; +import { + DEFAULT_ACCESS_TOKEN_TTL, + DEFAULT_REFRESH_TOKEN_TTL, + DEFAULT_TOKEN_AUDIENCE, + DEFAULT_TOKEN_ISSUER, +} from "@basango/domain/constants"; +import { type JWTPayload, SignJWT, jwtVerify } from "jose"; import { env } from "#api/config"; @@ -6,35 +14,144 @@ export type Session = { user: { id: string; email: string; - full_name?: string; + name?: string; }; }; export type VerifiedJWTPayload = JWTPayload & { + tokenType: TokenType; user: { id: string; email: string; - full_name?: string; + name?: string; }; }; +type TokenType = "access" | "refresh"; + +export type SessionTokens = { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: string; + refreshTokenExpiresAt: string; +}; + +const encoder = new TextEncoder(); + +function getSecretKey() { + return encoder.encode(env("BASANGO_JWT_SECRET")); +} + +export async function getSession(db: Database, accessToken?: string): Promise { + const session = await verifyAccessToken(accessToken); + + if (!session) { + return null; + } + + const user = await getUserById(db, { + email: session.user.email, + id: session.user.id, + }); + + if (!user || user.isLocked) { + return null; + } + + return { + user: { + email: user.email, + id: user.id, + name: user.name, + }, + }; +} + +async function createToken(session: Session, tokenType: TokenType, expiresIn: string) { + return new SignJWT({ + tokenType, + user: session.user, + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setAudience(DEFAULT_TOKEN_AUDIENCE) + .setIssuer(DEFAULT_TOKEN_ISSUER) + .setExpirationTime(expiresIn) + .sign(getSecretKey()); +} + +export async function createSessionTokens(session: Session): Promise { + const [accessToken, refreshToken] = await Promise.all([ + createToken(session, "access", DEFAULT_ACCESS_TOKEN_TTL), + createToken(session, "refresh", DEFAULT_REFRESH_TOKEN_TTL), + ]); + + const issuedAt = Date.now(); + const accessTokenExpiresAt = new Date( + issuedAt + formatTTL(DEFAULT_ACCESS_TOKEN_TTL), + ).toISOString(); + const refreshTokenExpiresAt = new Date( + issuedAt + formatTTL(DEFAULT_REFRESH_TOKEN_TTL), + ).toISOString(); + + return { + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + }; +} + export async function verifyAccessToken(accessToken?: string): Promise { - if (!accessToken) return null; + return verifyToken(accessToken, "access"); +} + +export async function verifyRefreshToken(refreshToken?: string): Promise { + return verifyToken(refreshToken, "refresh"); +} + +async function verifyToken( + token: string | undefined, + expectedType: TokenType, +): Promise { + if (!token) return null; try { - const { payload } = await jwtVerify( - accessToken, - new TextEncoder().encode(env("BASANGO_JWT_SECRET")), - ); + const { payload } = await jwtVerify(token, getSecretKey(), { + audience: DEFAULT_TOKEN_AUDIENCE, + issuer: DEFAULT_TOKEN_ISSUER, + }); + + if (payload.tokenType !== expectedType) { + return null; + } return { user: { email: payload.user.email, - full_name: payload.user.full_name, id: payload.user.id, + name: payload.user.name, }, }; } catch (_error: unknown) { return null; } } + +function formatTTL(ttl: string) { + const match = ttl.match(/^(\d+)([smhd])$/); + if (!match) return 0; + const [, rawValue, rawUnit] = match; + if (!rawValue || !rawUnit) { + return 0; + } + const value = Number.parseInt(rawValue, 10); + const multipliers = { + d: 86_400_000, + h: 3_600_000, + m: 60_000, + s: 1_000, + } as const; + const unit = rawUnit as keyof typeof multipliers; + return value * (multipliers[unit] ?? 1_000); +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 50df2b2..b8428b9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -12,7 +12,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "client-only": "^0.0.1", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "lucide-react": "^0.553.0", "next": "catalog:", "next-international": "^1.3.1", @@ -26,7 +26,7 @@ "server-only": "^0.0.1", "sonner": "^2.0.7", "superjson": "^2.2.5", - "zod": "^4.1.12", + "zod": "catalog:", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/apps/dashboard/src/app/[locale]/(public)/login/page.tsx b/apps/dashboard/src/app/[locale]/(public)/login/page.tsx index 229d6e4..af7842c 100644 --- a/apps/dashboard/src/app/[locale]/(public)/login/page.tsx +++ b/apps/dashboard/src/app/[locale]/(public)/login/page.tsx @@ -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 (
-
@@ -24,7 +14,7 @@ export default function LoginPage() { verification placeholder
diff --git a/apps/dashboard/src/app/[locale]/page.tsx b/apps/dashboard/src/app/[locale]/page.tsx new file mode 100644 index 0000000..e547d59 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + redirect("/dashboard"); +} diff --git a/apps/dashboard/src/app/api/session/refresh/route.ts b/apps/dashboard/src/app/api/session/refresh/route.ts new file mode 100644 index 0000000..2b80ec3 --- /dev/null +++ b/apps/dashboard/src/app/api/session/refresh/route.ts @@ -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({ + 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; +} diff --git a/apps/dashboard/src/components/forms/login-form.tsx b/apps/dashboard/src/components/forms/login-form.tsx index 4bbf46e..0c44780 100644 --- a/apps/dashboard/src/components/forms/login-form.tsx +++ b/apps/dashboard/src/components/forms/login-form.tsx @@ -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; 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 ( -
+
-

Login to your account

+

Basango Dashboard

Enter your email below to login to your account

- - Email - - - - - - - - - - Or continue with - - + {fieldState.invalid && } + + )} + /> + + ( + + + + {fieldState.invalid && } + + )} + /> + + + Login + + + Don't have an account?{" "} diff --git a/apps/dashboard/src/components/shell/page-header.tsx b/apps/dashboard/src/components/shell/page-header.tsx index 1867b2d..eb33155 100644 --- a/apps/dashboard/src/components/shell/page-header.tsx +++ b/apps/dashboard/src/components/shell/page-header.tsx @@ -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 ( -
+
- - - - Building Your Application - - - - Data Fetching - - - +
+
+
); } - -//
-//
-// -// -// -// -// -// -// -// -// -//
-//
-// -// -//
-//
diff --git a/apps/dashboard/src/components/sidebar/app-sidebar-info.tsx b/apps/dashboard/src/components/sidebar/app-sidebar-info.tsx index 05bf48f..3cace6c 100644 --- a/apps/dashboard/src/components/sidebar/app-sidebar-info.tsx +++ b/apps/dashboard/src/components/sidebar/app-sidebar-info.tsx @@ -12,11 +12,8 @@ export function AppSidebarInfo() { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" size="lg" > -
- Logo -
- Basango + Basango Dashboard v{version}
diff --git a/apps/dashboard/src/components/sidebar/app-sidebar-user.tsx b/apps/dashboard/src/components/sidebar/app-sidebar-user.tsx index c29bb5b..d9f3905 100644 --- a/apps/dashboard/src/components/sidebar/app-sidebar-user.tsx +++ b/apps/dashboard/src/components/sidebar/app-sidebar-user.tsx @@ -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 ( @@ -31,11 +44,13 @@ export function AppSidebarUser() { size="lg" > - BN + + {getInitials(user?.name ?? user?.email ?? "")} +
- Bernard Ng - bernard.ng@example.com + {user?.name ?? user?.email ?? ""} + {user?.email ?? ""}
@@ -49,38 +64,18 @@ export function AppSidebarUser() {
- BN + + {getInitials(user?.name ?? user?.email ?? "")} +
- Bernard Ng - bernard.ng@example.com + {user?.name ?? user?.email ?? ""} + {user?.email ?? ""}
- - - - Upgrade to Pro - - - - - - - Account - - - - Billing - - - - Notifications - - - - + Log out diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx new file mode 100644 index 0000000..3db32be --- /dev/null +++ b/apps/dashboard/src/components/theme-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/apps/dashboard/src/hooks/use-user.ts b/apps/dashboard/src/hooks/use-user.ts new file mode 100644 index 0000000..1134d71 --- /dev/null +++ b/apps/dashboard/src/hooks/use-user.ts @@ -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, + }; +} diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index 6a89d25..c61885e 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -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 { + 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, + }); +} diff --git a/apps/dashboard/src/stores/user-store.ts b/apps/dashboard/src/stores/user-store.ts new file mode 100644 index 0000000..f5137e4 --- /dev/null +++ b/apps/dashboard/src/stores/user-store.ts @@ -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()( + persist( + (set) => ({ + setUser: (user) => set({ user }), + user: null, + }), + { + name: "basango/user", + partialize: (state) => ({ user: state.user }), + storage: createJSONStorage(() => sessionStorage), + }, + ), +); diff --git a/apps/dashboard/src/trpc/client.tsx b/apps/dashboard/src/trpc/client.tsx index a49b197..0dfbaaa 100644 --- a/apps/dashboard/src/trpc/client.tsx +++ b/apps/dashboard/src/trpc/client.tsx @@ -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(); @@ -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`, diff --git a/apps/dashboard/src/trpc/server.tsx b/apps/dashboard/src/trpc/server.tsx index edf8c5a..3af3fa3 100644 --- a/apps/dashboard/src/trpc/server.tsx +++ b/apps/dashboard/src/trpc/server.tsx @@ -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({ 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`, diff --git a/apps/dashboard/src/utils/auth/client.ts b/apps/dashboard/src/utils/auth/client.ts new file mode 100644 index 0000000..5891eca --- /dev/null +++ b/apps/dashboard/src/utils/auth/client.ts @@ -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}`; +} diff --git a/apps/dashboard/src/utils/auth/server.ts b/apps/dashboard/src/utils/auth/server.ts new file mode 100644 index 0000000..962aeb3 --- /dev/null +++ b/apps/dashboard/src/utils/auth/server.ts @@ -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; +} diff --git a/apps/dashboard/src/utils/utils.ts b/apps/dashboard/src/utils/utils.ts index 1fad1ad..a92bae8 100644 --- a/apps/dashboard/src/utils/utils.ts +++ b/apps/dashboard/src/utils/utils.ts @@ -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); diff --git a/biome.json b/biome.json index d1fed0d..17914ec 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", "assist": { "actions": { "source": { diff --git a/bun.lock b/bun.lock index c0f3cc8..f49a800 100644 --- a/bun.lock +++ b/bun.lock @@ -5,17 +5,17 @@ "name": "basango", "devDependencies": { "@basango/tsconfig": "workspace:*", - "@biomejs/biome": "^2.3.1", - "@commitlint/cli": "^20.1.0", - "@commitlint/config-conventional": "^20.0.0", - "@manypkg/cli": "^0.25.1", - "@types/bun": "catalog:", - "@types/node": "catalog:", - "commitizen": "^4.3.1", - "cz-conventional-changelog": "^3.3.0", - "husky": "^9.1.7", - "turbo": "^2.6.1", - "typescript": "catalog:", + "@biomejs/biome": "latest", + "@commitlint/cli": "latest", + "@commitlint/config-conventional": "latest", + "@manypkg/cli": "latest", + "@types/bun": "latest", + "@types/node": "latest", + "commitizen": "latest", + "cz-conventional-changelog": "latest", + "husky": "latest", + "turbo": "latest", + "typescript": "latest", }, }, "apps/api": { @@ -73,7 +73,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "client-only": "^0.0.1", - "date-fns": "^4.1.0", + "date-fns": "catalog:", "lucide-react": "^0.553.0", "next": "catalog:", "next-international": "^1.3.1", @@ -87,7 +87,7 @@ "server-only": "^0.0.1", "sonner": "^2.0.7", "superjson": "^2.2.5", - "zod": "^4.1.12", + "zod": "catalog:", "zustand": "^5.0.8", }, "devDependencies": { @@ -169,7 +169,12 @@ "packages/encryption": { "name": "@basango/encryption", "dependencies": { + "@basango/domain": "workspace:*", "@devscast/config": "catalog:", + "bcrypt": "^6.0.0", + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", }, }, "packages/logger": { @@ -467,23 +472,23 @@ "@basango/ui": ["@basango/ui@workspace:packages/ui"], - "@biomejs/biome": ["@biomejs/biome@2.3.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.4", "@biomejs/cli-darwin-x64": "2.3.4", "@biomejs/cli-linux-arm64": "2.3.4", "@biomejs/cli-linux-arm64-musl": "2.3.4", "@biomejs/cli-linux-x64": "2.3.4", "@biomejs/cli-linux-x64-musl": "2.3.4", "@biomejs/cli-win32-arm64": "2.3.4", "@biomejs/cli-win32-x64": "2.3.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w=="], + "@biomejs/biome": ["@biomejs/biome@2.3.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.6", "@biomejs/cli-darwin-x64": "2.3.6", "@biomejs/cli-linux-arm64": "2.3.6", "@biomejs/cli-linux-arm64-musl": "2.3.6", "@biomejs/cli-linux-x64": "2.3.6", "@biomejs/cli-linux-x64-musl": "2.3.6", "@biomejs/cli-win32-arm64": "2.3.6", "@biomejs/cli-win32-x64": "2.3.6" }, "bin": { "biome": "bin/biome" } }, "sha512-oqUhWyU6tae0MFsr/7iLe++QWRg+6jtUhlx9/0GmCWDYFFrK366sBLamNM7D9Y+c7YSynUFKr8lpEp1r6Sk7eA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P4JWE5d8UayBxYe197QJwyW4ZHp0B+zvRIGCusOm1WbxmlhpAQA1zEqQuunHgSIzvyEEp4TVxiKGXNFZPg7r9Q=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I4rTebj+F/L9K93IU7yTFs8nQ6EhaCOivxduRha4w4WEZK80yoZ8OAdR1F33m4yJ/NfUuTUbP/Wjs+vKjlCoWA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-JjYy83eVBnvuINZiqyFO7xx72v8Srh4hsgaacSBCjC22DwM6+ZvnX1/fj8/SBiLuUOfZ8YhU2pfq2Dzakeyg1A=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oK1NpIXIixbJ/4Tcx40cwiieqah6rRUtMGOHDeK2ToT7yUFVEvXUGRKqH0O4hqZ9tW8TcXNZKfgRH6xrsjVtGg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjPXzy5yN9wusIoX+8Zp4p6cL8r0NzJCXg/4r1KLVveIPXd2jKVlqZ6ZyzEq385WwU3OX5KOwQYLQsOc788waQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-QvxB8GHQeaO4FCtwJpJjCgJkbHBbWxRHUxQlod+xeaYE6gtJdSkYkuxdKAQUZEOIsec+PeaDAhW9xjzYbwmOFA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-YM7hLHpwjdt8R7+O2zS1Vo2cKgqEeptiXB1tWW1rgjN5LlpZovBVKtg7zfwfRrFx3i08aNZThYpTcowpTlczug=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-psgNEYgMAobY5h+QHRBVR9xvg2KocFuBKm6axZWB/aD12NWhQjiVFQUjV6wMXhlH4iT0Q9c3yK5JFRiDC/rzHA=="], "@commitlint/cli": ["@commitlint/cli@20.1.0", "", { "dependencies": { "@commitlint/format": "^20.0.0", "@commitlint/lint": "^20.0.0", "@commitlint/load": "^20.1.0", "@commitlint/read": "^20.0.0", "@commitlint/types": "^20.0.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-pW5ujjrOovhq5RcYv5xCpb4GkZxkO2+GtOdBW2/qrr0Ll9tl3PX0aBBobGQl3mdZUbOBgwAexEQLeH6uxL0VYg=="], @@ -1019,6 +1024,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], @@ -1057,7 +1064,7 @@ "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], - "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], @@ -1175,6 +1182,8 @@ "basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="], + "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], + "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], @@ -1971,10 +1980,14 @@ "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-html-parser": ["node-html-parser@7.0.1", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA=="], @@ -2613,8 +2626,6 @@ "@babel/traverse--for-generate-function-map/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@basango/dashboard/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2693,8 +2704,14 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "@jest/environment/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@jest/fake-timers/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@jest/transform/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@jest/types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2759,8 +2776,20 @@ "@turbo/workspaces/semver": ["semver@7.6.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w=="], + "@types/bcrypt/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/conventional-commits-parser/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/glob/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/graceful-fs/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@types/inquirer/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "^1.9.0" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], + "@types/pg/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/through/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -2777,8 +2806,14 @@ "bullmq/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "chrome-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "chromium-edge-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -2845,10 +2880,18 @@ "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "jest-environment-node/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "jest-haste-map/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "jest-message-util/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-mock/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "jest-util/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2857,6 +2900,8 @@ "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-worker/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/package.json b/package.json index babcdf1..9601a37 100644 --- a/package.json +++ b/package.json @@ -24,17 +24,17 @@ }, "devDependencies": { "@basango/tsconfig": "workspace:*", - "@biomejs/biome": "^2.3.1", + "@biomejs/biome": "^2.3.6", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@manypkg/cli": "^0.25.1", - "@types/bun": "catalog:", - "@types/node": "catalog:", + "@types/bun": "^1.3.2", + "@types/node": "^24.10.1", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "husky": "^9.1.7", "turbo": "^2.6.1", - "typescript": "catalog:" + "typescript": "^5.9.3" }, "engines": { "node": ">=22" diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index 1c18213..9c9c7dd 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -1,2 +1,3 @@ export * from "./articles"; export * from "./sources"; +export * from "./users"; diff --git a/packages/db/src/queries/users.ts b/packages/db/src/queries/users.ts new file mode 100644 index 0000000..190e201 --- /dev/null +++ b/packages/db/src/queries/users.ts @@ -0,0 +1,23 @@ +import { and, eq, ilike } from "drizzle-orm"; + +import { Database } from "#db/client"; +import { users } from "#db/schema"; + +export type User = typeof users.$inferSelect; + +export async function getUserByEmail(db: Database, email: string): Promise { + return db.query.users.findFirst({ + where: ilike(users.email, email), + }); +} + +export async function getUserById( + db: Database, + params: { id: string; email?: string }, +): Promise { + const { id, email } = params; + + return db.query.users.findFirst({ + where: email ? and(eq(users.id, id), ilike(users.email, email)) : eq(users.id, id), + }); +} diff --git a/packages/domain/src/constants.ts b/packages/domain/src/constants.ts index c0c118a..e044a75 100644 --- a/packages/domain/src/constants.ts +++ b/packages/domain/src/constants.ts @@ -23,3 +23,14 @@ export const DEFAULT_SOURCE_IMAGE = "https://devscast.org/images/sources/"; export const DEFAULT_PUBLICATION_GRAPH_DAYS = 30; export const DEFAULT_CATEGORY_SHARES_LIMIT = 10; export const DEFAULT_TIMEZONE = "Africa/Lubumbashi"; + +export const DEFAULT_ACCESS_TOKEN_COOKIE = "basango.access_token"; +export const DEFAULT_REFRESH_TOKEN_COOKIE = "basango.refresh_token"; +export const DEFAULT_ENCRYPTION_ALGORITHM = "aes-256-gcm"; +export const DEFAULT_IV_LENGTH = 16; +export const DEFAULT_AUTH_TAG_LENGTH = 16; +export const DEFAULT_BCRYPT_SALT_ROUNDS = 12; +export const DEFAULT_TOKEN_AUDIENCE = "basango_dashboard"; +export const DEFAULT_TOKEN_ISSUER = "basango_api"; +export const DEFAULT_ACCESS_TOKEN_TTL = "15m"; +export const DEFAULT_REFRESH_TOKEN_TTL = "7d"; diff --git a/packages/domain/src/models/auth.ts b/packages/domain/src/models/auth.ts new file mode 100644 index 0000000..610a63a --- /dev/null +++ b/packages/domain/src/models/auth.ts @@ -0,0 +1,18 @@ +import { z } from "@hono/zod-openapi"; + +export const loginSchema = z.object({ + email: z.email().openapi({ + description: "Email address used to authenticate the user.", + example: "user@example.com", + }), + password: z.string().min(8).openapi({ + description: "Account password.", + example: "••••••••", + }), +}); + +export const refreshSessionSchema = z.object({ + refreshToken: z.string().min(1).openapi({ + description: "Refresh token returned when logging in.", + }), +}); diff --git a/packages/domain/src/models/index.ts b/packages/domain/src/models/index.ts index 9870807..e1c742a 100644 --- a/packages/domain/src/models/index.ts +++ b/packages/domain/src/models/index.ts @@ -1,4 +1,5 @@ export * from "./articles"; +export * from "./auth"; export * from "./shared"; export * from "./sources"; export * from "./users"; diff --git a/packages/encryption/package.json b/packages/encryption/package.json index b2204e4..76b1c00 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,11 @@ { "dependencies": { - "@devscast/config": "catalog:" + "@basango/domain": "workspace:*", + "@devscast/config": "catalog:", + "bcrypt": "^6.0.0" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0" }, "main": "src/index.ts", "name": "@basango/encryption", diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts index 1176e75..3ed7cc3 100644 --- a/packages/encryption/src/index.ts +++ b/packages/encryption/src/index.ts @@ -1,13 +1,16 @@ import crypto from "node:crypto"; +import { + DEFAULT_AUTH_TAG_LENGTH, + DEFAULT_BCRYPT_SALT_ROUNDS, + DEFAULT_ENCRYPTION_ALGORITHM, + DEFAULT_IV_LENGTH, +} from "@basango/domain/constants"; import { createEnvAccessor } from "@devscast/config"; +import * as bcrypt from "bcrypt"; export const env = createEnvAccessor(["BASANGO_ENCRYPTION_KEY"] as const); -const ALGORITHM = "aes-256-gcm"; -const IV_LENGTH = 16; -const AUTH_TAG_LENGTH = 16; - function getKey(): Buffer { const key = env("BASANGO_ENCRYPTION_KEY"); @@ -24,8 +27,8 @@ function getKey(): Buffer { */ export function encrypt(text: string): string { const key = getKey(); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const iv = crypto.randomBytes(DEFAULT_IV_LENGTH); + const cipher = crypto.createCipheriv(DEFAULT_ENCRYPTION_ALGORITHM, key, iv); let encrypted = cipher.update(text, "utf8", "hex"); encrypted += cipher.final("hex"); @@ -50,11 +53,14 @@ export function decrypt(encryptedPayload: string): string { const dataBuffer = Buffer.from(encryptedPayload, "base64"); // Extract IV, auth tag, and encrypted data - const iv = dataBuffer.subarray(0, IV_LENGTH); - const authTag = dataBuffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const encryptedText = dataBuffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const iv = dataBuffer.subarray(0, DEFAULT_IV_LENGTH); + const authTag = dataBuffer.subarray( + DEFAULT_IV_LENGTH, + DEFAULT_IV_LENGTH + DEFAULT_AUTH_TAG_LENGTH, + ); + const encryptedText = dataBuffer.subarray(DEFAULT_IV_LENGTH + DEFAULT_AUTH_TAG_LENGTH); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + const decipher = crypto.createDecipheriv(DEFAULT_ENCRYPTION_ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedText.toString("hex"), "hex", "utf8"); @@ -74,3 +80,11 @@ export function md5(str: string): string { export function generateRandomBytes(size: number): string { return crypto.randomBytes(size).toString("hex"); } + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, DEFAULT_BCRYPT_SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hashed: string): Promise { + return bcrypt.compare(password, hashed); +} diff --git a/packages/encryption/tsconfig.json b/packages/encryption/tsconfig.json index c390cc9..629be12 100644 --- a/packages/encryption/tsconfig.json +++ b/packages/encryption/tsconfig.json @@ -1,4 +1,10 @@ { + "compilerOptions": { + "paths": { + "#domain/*": ["../../domain/src/*"], + "#encryption/*": ["./src/*"] + } + }, "exclude": ["node_modules"], "extends": "@basango/tsconfig/base.json", "include": ["src/**/*"]