feat(api): authentication
This commit is contained in:
@@ -27,6 +27,7 @@ Conventions
|
|||||||
- Prefer named exports in libraries. Avoid barrel files unless necessary.
|
- Prefer named exports in libraries. Avoid barrel files unless necessary.
|
||||||
- Use `workspace:*` for internal dependencies; do not hardcode versions.
|
- Use `workspace:*` for internal dependencies; do not hardcode versions.
|
||||||
- Keep changes minimal and localized; avoid cross-cutting refactors without discussion.
|
- 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
|
Tasks & Commands
|
||||||
- Install: `bun install` (run at repo root only).
|
- Install: `bun install` (run at repo root only).
|
||||||
@@ -67,4 +68,3 @@ Gotchas
|
|||||||
Contact Points
|
Contact Points
|
||||||
- Architecture overview: `docs/architecture.md`.
|
- Architecture overview: `docs/architecture.md`.
|
||||||
- Forms handling patterns: `docs/forms-handling.md`.
|
- Forms handling patterns: `docs/forms-handling.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Database, db } from "@basango/db/client";
|
import { Database, db } from "@basango/db/client";
|
||||||
import { initTRPC } from "@trpc/server";
|
import { TRPCError, initTRPC } from "@trpc/server";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { withAuthentication } from "#api/trpc/middlewares/auth";
|
import { withAuthentication } from "#api/trpc/middlewares/auth";
|
||||||
import { withDatabase } from "#api/trpc/middlewares/db";
|
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";
|
import { getGeoContext } from "#api/utils/geo";
|
||||||
|
|
||||||
type TRPCContext = {
|
type TRPCContext = {
|
||||||
@@ -16,7 +16,7 @@ type TRPCContext = {
|
|||||||
|
|
||||||
export const createTRPCContext = async (_: unknown, c: Context): Promise<TRPCContext> => {
|
export const createTRPCContext = async (_: unknown, c: Context): Promise<TRPCContext> => {
|
||||||
const accessToken = c.req.header("Authorization")?.split(" ")[1];
|
const accessToken = c.req.header("Authorization")?.split(" ")[1];
|
||||||
const session = await verifyAccessToken(accessToken);
|
const session = await getSession(db, accessToken);
|
||||||
const geo = getGeoContext(c.req);
|
const geo = getGeoContext(c.req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,13 +51,13 @@ export const publicProcedure = t.procedure.use(withDatabaseMiddleware);
|
|||||||
|
|
||||||
export const protectedProcedure = t.procedure
|
export const protectedProcedure = t.procedure
|
||||||
.use(withDatabaseMiddleware)
|
.use(withDatabaseMiddleware)
|
||||||
.use(withAutenticationMiddleware) // NOTE: This is needed to ensure that the teamId is set in the context
|
.use(withAutenticationMiddleware)
|
||||||
.use(async (opts) => {
|
.use(async (opts) => {
|
||||||
const { session } = opts.ctx;
|
const { session } = opts.ctx;
|
||||||
|
|
||||||
// if (!session) {
|
if (!session) {
|
||||||
// throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
return opts.next({
|
return opts.next({
|
||||||
ctx: {
|
ctx: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Database } from "@basango/db/client";
|
import type { Database } from "@basango/db/client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
// import { TRPCError } from "@trpc/server";
|
|
||||||
|
|
||||||
import type { Session } from "#api/utils/auth";
|
import type { Session } from "#api/utils/auth";
|
||||||
|
|
||||||
@@ -18,14 +17,12 @@ export const withAuthentication = async <TReturn>(opts: {
|
|||||||
}) => {
|
}) => {
|
||||||
const { ctx, next } = opts;
|
const { ctx, next } = opts;
|
||||||
|
|
||||||
// const userId = ctx.session?.user?.id;
|
if (!ctx.session) {
|
||||||
|
throw new TRPCError({
|
||||||
// if (!userId) {
|
code: "UNAUTHORIZED",
|
||||||
// throw new TRPCError({
|
message: "Authentication is required to access this resource.",
|
||||||
// code: "UNAUTHORIZED",
|
});
|
||||||
// message: "No permission to access",
|
}
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
|||||||
|
|
||||||
import { createTRPCRouter } from "#api/trpc/init";
|
import { createTRPCRouter } from "#api/trpc/init";
|
||||||
import { articlesRouter } from "#api/trpc/routers/articles";
|
import { articlesRouter } from "#api/trpc/routers/articles";
|
||||||
|
import { authRouter } from "#api/trpc/routers/auth";
|
||||||
import { sourcesRouter } from "#api/trpc/routers/sources";
|
import { sourcesRouter } from "#api/trpc/routers/sources";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
articles: articlesRouter,
|
articles: articlesRouter,
|
||||||
|
auth: authRouter,
|
||||||
sources: sourcesRouter,
|
sources: sourcesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
+126
-9
@@ -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";
|
import { env } from "#api/config";
|
||||||
|
|
||||||
@@ -6,35 +14,144 @@ export type Session = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
full_name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerifiedJWTPayload = JWTPayload & {
|
export type VerifiedJWTPayload = JWTPayload & {
|
||||||
|
tokenType: TokenType;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: 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<Session | null> {
|
||||||
|
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<SessionTokens> {
|
||||||
|
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<Session | null> {
|
export async function verifyAccessToken(accessToken?: string): Promise<Session | null> {
|
||||||
if (!accessToken) return null;
|
return verifyToken(accessToken, "access");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyRefreshToken(refreshToken?: string): Promise<Session | null> {
|
||||||
|
return verifyToken(refreshToken, "refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyToken(
|
||||||
|
token: string | undefined,
|
||||||
|
expectedType: TokenType,
|
||||||
|
): Promise<Session | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify<VerifiedJWTPayload>(
|
const { payload } = await jwtVerify<VerifiedJWTPayload>(token, getSecretKey(), {
|
||||||
accessToken,
|
audience: DEFAULT_TOKEN_AUDIENCE,
|
||||||
new TextEncoder().encode(env("BASANGO_JWT_SECRET")),
|
issuer: DEFAULT_TOKEN_ISSUER,
|
||||||
);
|
});
|
||||||
|
|
||||||
|
if (payload.tokenType !== expectedType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
email: payload.user.email,
|
email: payload.user.email,
|
||||||
full_name: payload.user.full_name,
|
|
||||||
id: payload.user.id,
|
id: payload.user.id,
|
||||||
|
name: payload.user.name,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error: unknown) {
|
} catch (_error: unknown) {
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"@trpc/server": "^11.7.1",
|
"@trpc/server": "^11.7.1",
|
||||||
"@trpc/tanstack-react-query": "^11.7.1",
|
"@trpc/tanstack-react-query": "^11.7.1",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "catalog:",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-international": "^1.3.1",
|
"next-international": "^1.3.1",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.5",
|
"superjson": "^2.2.5",
|
||||||
"zod": "^4.1.12",
|
"zod": "catalog:",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import { GalleryVerticalEnd } from "lucide-react";
|
|
||||||
|
|
||||||
import { LoginForm } from "#dashboard/components/forms/login-form";
|
import { LoginForm } from "#dashboard/components/forms/login-form";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-svh lg:grid-cols-2">
|
<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 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="flex flex-1 items-center justify-center">
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
@@ -24,7 +14,7 @@ export default function LoginPage() {
|
|||||||
<img
|
<img
|
||||||
alt="verification placeholder"
|
alt="verification placeholder"
|
||||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
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>
|
||||||
</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 {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldSeparator,
|
|
||||||
} from "@basango/ui/components/field";
|
} from "@basango/ui/components/field";
|
||||||
import { Input } from "@basango/ui/components/input";
|
import { Input } from "@basango/ui/components/input";
|
||||||
|
import { SubmitButton } from "@basango/ui/components/submit-button";
|
||||||
import { cn } from "@basango/ui/lib/utils";
|
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">) {
|
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 (
|
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>
|
<FieldGroup>
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<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">
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
Enter your email below to login to your account
|
Enter your email below to login to your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
<Controller
|
||||||
<Input id="email" placeholder="m@example.com" required type="email" />
|
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"
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
|
||||||
<a className="ml-auto text-sm underline-offset-4 hover:underline" href="#">
|
<a className="ml-auto text-sm underline-offset-4 hover:underline" href="#">
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Input id="password" required type="password" />
|
<Input
|
||||||
</Field>
|
{...field}
|
||||||
<Field>
|
aria-invalid={fieldState.invalid}
|
||||||
<Button type="submit">Login</Button>
|
autoComplete="current-password"
|
||||||
</Field>
|
disabled={mutation.isPending}
|
||||||
<FieldSeparator>Or continue with</FieldSeparator>
|
id={field.name}
|
||||||
<Field>
|
type="password"
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||||
Login with GitHub
|
</Field>
|
||||||
</Button>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton className="w-full" isSubmitting={mutation.isPending} type="submit">
|
||||||
|
Login
|
||||||
|
</SubmitButton>
|
||||||
|
|
||||||
|
<Field>
|
||||||
<FieldDescription className="text-center">
|
<FieldDescription className="text-center">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<a className="underline underline-offset-4" href="#">
|
<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 { Separator } from "@basango/ui/components/separator";
|
||||||
//import { LanguageSelector, ThemeSelector } from "@/components/ui/shared/settings";
|
|
||||||
import { SidebarTrigger } from "@basango/ui/components/sidebar";
|
import { SidebarTrigger } from "@basango/ui/components/sidebar";
|
||||||
|
|
||||||
|
import { ThemeToggle } from "#dashboard/components/theme-toggle";
|
||||||
|
|
||||||
export function PageHeader() {
|
export function PageHeader() {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
|
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
|
||||||
<Breadcrumb>
|
</div>
|
||||||
<BreadcrumbList>
|
<div className="flex items-center gap-2 px-4">
|
||||||
<BreadcrumbItem className="hidden md:block">
|
<ThemeToggle />
|
||||||
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
size="lg"
|
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">
|
<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>
|
<span className="truncate text-xs">v{version}</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Avatar, AvatarFallback } from "@basango/ui/components/avatar";
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
@@ -16,10 +15,24 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@basango/ui/components/sidebar";
|
} 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() {
|
export function AppSidebarUser() {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, setUser } = useUser();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearSessionTokens();
|
||||||
|
setUser(null);
|
||||||
|
router.push(`/login`);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -31,11 +44,13 @@ export function AppSidebarUser() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-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>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">Bernard Ng</span>
|
<span className="truncate font-medium">{user?.name ?? user?.email ?? ""}</span>
|
||||||
<span className="truncate text-xs">bernard.ng@example.com</span>
|
<span className="truncate text-xs">{user?.email ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -49,38 +64,18 @@ export function AppSidebarUser() {
|
|||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-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>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">Bernard Ng</span>
|
<span className="truncate font-medium">{user?.name ?? user?.email ?? ""}</span>
|
||||||
<span className="truncate text-xs">bernard.ng@example.com</span>
|
<span className="truncate text-xs">{user?.email ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<DropdownMenuItem>
|
|
||||||
<Sparkles />
|
|
||||||
Upgrade to Pro
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<BadgeCheck />
|
|
||||||
Account
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CreditCard />
|
|
||||||
Billing
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Bell />
|
|
||||||
Notifications
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LogOut />
|
<LogOut />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</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";
|
import { createI18nMiddleware } from "next-international/middleware";
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES = ["en"] as const;
|
||||||
|
const DEFAULT_LOCALE = SUPPORTED_LOCALES[0];
|
||||||
|
|
||||||
const I18nMiddleware = createI18nMiddleware({
|
const I18nMiddleware = createI18nMiddleware({
|
||||||
defaultLocale: "en",
|
defaultLocale: DEFAULT_LOCALE,
|
||||||
locales: ["en"],
|
locales: SUPPORTED_LOCALES as unknown as string[],
|
||||||
urlMappingStrategy: "rewrite",
|
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) {
|
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
|
if (accessToken && pathname === "/login") {
|
||||||
// const pathnameWithoutLocale = pathnameLocale
|
const redirectUrl = new URL(`/${locale}/dashboard`, request.url);
|
||||||
// ? nextUrl.pathname.slice(pathnameLocale.length + 1)
|
return NextResponse.redirect(redirectUrl);
|
||||||
// : nextUrl.pathname;
|
}
|
||||||
|
|
||||||
// // Create a new URL without the locale in the pathname
|
const i18nResponse = await I18nMiddleware(request);
|
||||||
// const newUrl = new URL(pathnameWithoutLocale || "/", request.url);
|
|
||||||
// const encodedSearchParams = `${newUrl?.pathname?.substring(1)}${newUrl.search}`;
|
|
||||||
// const session = request.cookies.get("token")?.value;
|
|
||||||
|
|
||||||
// // 1. Not authenticated
|
if (refreshedTokens) {
|
||||||
// if (
|
setSessionCookies(i18nResponse, refreshedTokens, request);
|
||||||
// !session &&
|
}
|
||||||
// newUrl.pathname !== "/login" &&
|
|
||||||
// !newUrl.pathname.includes("/i/") &&
|
|
||||||
// !newUrl.pathname.includes("/s/") &&
|
|
||||||
// !newUrl.pathname.includes("/verify")
|
|
||||||
// ) {
|
|
||||||
// const url = new URL("/login", request.url);
|
|
||||||
|
|
||||||
// if (encodedSearchParams) {
|
return i18nResponse;
|
||||||
// url.searchParams.append("return_to", encodedSearchParams);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return NextResponse.redirect(url);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// If all checks pass, return the original or updated response
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
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 { useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { getClientAccessToken } from "#dashboard/utils/auth/client";
|
||||||
|
|
||||||
import { makeQueryClient } from "./query-client";
|
import { makeQueryClient } from "./query-client";
|
||||||
|
|
||||||
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
|
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
|
||||||
@@ -44,11 +46,12 @@ export function TRPCReactProvider(
|
|||||||
links: [
|
links: [
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
headers: async () => {
|
headers: async () => {
|
||||||
//const token = window.localStorage.getItem("auth_token");
|
const token = getClientAccessToken();
|
||||||
|
return token
|
||||||
return {
|
? {
|
||||||
//Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
};
|
}
|
||||||
|
: {};
|
||||||
},
|
},
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { getServerAccessToken } from "#dashboard/utils/auth/server";
|
||||||
|
|
||||||
import { makeQueryClient } from "./query-client";
|
import { makeQueryClient } from "./query-client";
|
||||||
|
|
||||||
// IMPORTANT: Create a stable getter for the query client that
|
// IMPORTANT: Create a stable getter for the query client that
|
||||||
@@ -23,14 +25,16 @@ export const trpc = createTRPCOptionsProxy<AppRouter>({
|
|||||||
links: [
|
links: [
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
async headers() {
|
async headers() {
|
||||||
//const token = window.localStorage.getItem("auth_token");
|
const token = await getServerAccessToken();
|
||||||
|
|
||||||
return {
|
return token
|
||||||
//Authorization: `Bearer ${token}`,
|
? {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
// "x-user-country": await getCountryCode(),
|
// "x-user-country": await getCountryCode(),
|
||||||
// "x-user-locale": await getLocale(),
|
// "x-user-locale": await getLocale(),
|
||||||
// "x-user-timezone": await getTimezone(),
|
// "x-user-timezone": await getTimezone(),
|
||||||
};
|
}
|
||||||
|
: {};
|
||||||
},
|
},
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
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));
|
}).format(+Math.round(bytes / 1024 ** unitIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function secondsToHoursAndMinutes(seconds: number) {
|
export function formatHoursMinutes(seconds: number) {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -5,17 +5,17 @@
|
|||||||
"name": "basango",
|
"name": "basango",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@basango/tsconfig": "workspace:*",
|
"@basango/tsconfig": "workspace:*",
|
||||||
"@biomejs/biome": "^2.3.1",
|
"@biomejs/biome": "latest",
|
||||||
"@commitlint/cli": "^20.1.0",
|
"@commitlint/cli": "latest",
|
||||||
"@commitlint/config-conventional": "^20.0.0",
|
"@commitlint/config-conventional": "latest",
|
||||||
"@manypkg/cli": "^0.25.1",
|
"@manypkg/cli": "latest",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "latest",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "latest",
|
||||||
"commitizen": "^4.3.1",
|
"commitizen": "latest",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "latest",
|
||||||
"husky": "^9.1.7",
|
"husky": "latest",
|
||||||
"turbo": "^2.6.1",
|
"turbo": "latest",
|
||||||
"typescript": "catalog:",
|
"typescript": "latest",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/api": {
|
"apps/api": {
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"@trpc/server": "^11.7.1",
|
"@trpc/server": "^11.7.1",
|
||||||
"@trpc/tanstack-react-query": "^11.7.1",
|
"@trpc/tanstack-react-query": "^11.7.1",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "catalog:",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-international": "^1.3.1",
|
"next-international": "^1.3.1",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.5",
|
"superjson": "^2.2.5",
|
||||||
"zod": "^4.1.12",
|
"zod": "catalog:",
|
||||||
"zustand": "^5.0.8",
|
"zustand": "^5.0.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -169,7 +169,12 @@
|
|||||||
"packages/encryption": {
|
"packages/encryption": {
|
||||||
"name": "@basango/encryption",
|
"name": "@basango/encryption",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@basango/domain": "workspace:*",
|
||||||
"@devscast/config": "catalog:",
|
"@devscast/config": "catalog:",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/logger": {
|
"packages/logger": {
|
||||||
@@ -467,23 +472,23 @@
|
|||||||
|
|
||||||
"@basango/ui": ["@basango/ui@workspace:packages/ui"],
|
"@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=="],
|
"@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/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/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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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-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-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-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-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=="],
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/@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-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/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=="],
|
"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-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=="],
|
"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=="],
|
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|||||||
+4
-4
@@ -24,17 +24,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@basango/tsconfig": "workspace:*",
|
"@basango/tsconfig": "workspace:*",
|
||||||
"@biomejs/biome": "^2.3.1",
|
"@biomejs/biome": "^2.3.6",
|
||||||
"@commitlint/cli": "^20.1.0",
|
"@commitlint/cli": "^20.1.0",
|
||||||
"@commitlint/config-conventional": "^20.0.0",
|
"@commitlint/config-conventional": "^20.0.0",
|
||||||
"@manypkg/cli": "^0.25.1",
|
"@manypkg/cli": "^0.25.1",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "^24.10.1",
|
||||||
"commitizen": "^4.3.1",
|
"commitizen": "^4.3.1",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"turbo": "^2.6.1",
|
"turbo": "^2.6.1",
|
||||||
"typescript": "catalog:"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./articles";
|
export * from "./articles";
|
||||||
export * from "./sources";
|
export * from "./sources";
|
||||||
|
export * from "./users";
|
||||||
|
|||||||
@@ -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<User | undefined> {
|
||||||
|
return db.query.users.findFirst({
|
||||||
|
where: ilike(users.email, email),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(
|
||||||
|
db: Database,
|
||||||
|
params: { id: string; email?: string },
|
||||||
|
): Promise<User | undefined> {
|
||||||
|
const { id, email } = params;
|
||||||
|
|
||||||
|
return db.query.users.findFirst({
|
||||||
|
where: email ? and(eq(users.id, id), ilike(users.email, email)) : eq(users.id, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,3 +23,14 @@ export const DEFAULT_SOURCE_IMAGE = "https://devscast.org/images/sources/";
|
|||||||
export const DEFAULT_PUBLICATION_GRAPH_DAYS = 30;
|
export const DEFAULT_PUBLICATION_GRAPH_DAYS = 30;
|
||||||
export const DEFAULT_CATEGORY_SHARES_LIMIT = 10;
|
export const DEFAULT_CATEGORY_SHARES_LIMIT = 10;
|
||||||
export const DEFAULT_TIMEZONE = "Africa/Lubumbashi";
|
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";
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./articles";
|
export * from "./articles";
|
||||||
|
export * from "./auth";
|
||||||
export * from "./shared";
|
export * from "./shared";
|
||||||
export * from "./sources";
|
export * from "./sources";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@devscast/config": "catalog:"
|
"@basango/domain": "workspace:*",
|
||||||
|
"@devscast/config": "catalog:",
|
||||||
|
"bcrypt": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0"
|
||||||
},
|
},
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"name": "@basango/encryption",
|
"name": "@basango/encryption",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import crypto from "node:crypto";
|
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 { createEnvAccessor } from "@devscast/config";
|
||||||
|
import * as bcrypt from "bcrypt";
|
||||||
|
|
||||||
export const env = createEnvAccessor(["BASANGO_ENCRYPTION_KEY"] as const);
|
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 {
|
function getKey(): Buffer {
|
||||||
const key = env("BASANGO_ENCRYPTION_KEY");
|
const key = env("BASANGO_ENCRYPTION_KEY");
|
||||||
|
|
||||||
@@ -24,8 +27,8 @@ function getKey(): Buffer {
|
|||||||
*/
|
*/
|
||||||
export function encrypt(text: string): string {
|
export function encrypt(text: string): string {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(DEFAULT_IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
const cipher = crypto.createCipheriv(DEFAULT_ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
|
||||||
let encrypted = cipher.update(text, "utf8", "hex");
|
let encrypted = cipher.update(text, "utf8", "hex");
|
||||||
encrypted += cipher.final("hex");
|
encrypted += cipher.final("hex");
|
||||||
@@ -50,11 +53,14 @@ export function decrypt(encryptedPayload: string): string {
|
|||||||
const dataBuffer = Buffer.from(encryptedPayload, "base64");
|
const dataBuffer = Buffer.from(encryptedPayload, "base64");
|
||||||
|
|
||||||
// Extract IV, auth tag, and encrypted data
|
// Extract IV, auth tag, and encrypted data
|
||||||
const iv = dataBuffer.subarray(0, IV_LENGTH);
|
const iv = dataBuffer.subarray(0, DEFAULT_IV_LENGTH);
|
||||||
const authTag = dataBuffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
const authTag = dataBuffer.subarray(
|
||||||
const encryptedText = dataBuffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
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);
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
let decrypted = decipher.update(encryptedText.toString("hex"), "hex", "utf8");
|
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 {
|
export function generateRandomBytes(size: number): string {
|
||||||
return crypto.randomBytes(size).toString("hex");
|
return crypto.randomBytes(size).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, DEFAULT_BCRYPT_SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hashed: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hashed);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#domain/*": ["../../domain/src/*"],
|
||||||
|
"#encryption/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"],
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user