Compare commits

...

10 Commits

Author SHA1 Message Date
0a9f011f08 🔒 security: handle unauthorization user 2026-01-21 10:29:48 +07:00
19b15b89d2 feat: user profile in navbar 2026-01-21 09:48:12 +07:00
eae3b2b3fc feat: create auth provider context 2026-01-20 11:27:09 +07:00
e27b18b22e feat: add client info in backend fetch header 2026-01-20 08:25:10 +07:00
cb436fe40c ♻️ refactor(auth): replace redirect flow to popup window 2026-01-09 09:27:44 +07:00
b2c21c5f01 feat: create provider callback handler 2026-01-09 08:23:14 +07:00
34b4ec6232 🚚 mv: change layout folder structure
Separate the navbar from the root layout, keeping the root layout clean. Create two child layout folders:
1. main: for basic layouts such as the navbar
2. clean: for clean layouts without any extra elements.
2026-01-08 15:03:07 +07:00
940e84d168 🔧 chore: create oauth endpoint req to backend 2026-01-08 14:58:21 +07:00
77eeaf1adc 🔧 chore: add handle oauth endpoint login 2026-01-07 23:28:07 +07:00
28cd3178b9 feat: add oauth button 2026-01-07 17:21:14 +07:00
26 changed files with 923 additions and 41 deletions

View File

@ -0,0 +1,7 @@
import AuthCallbackIndex from "@/features/authCallback";
const page = async () => {
return <AuthCallbackIndex />;
};
export default page;

13
app/(main)/layout.tsx Normal file
View File

@ -0,0 +1,13 @@
import Navbar from "@/shared/widgets/navbar/components/Navbar";
import React from "react";
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div className="max-w-400 mx-auto relative">
<Navbar />
<div className="pt-16">{children}</div>
</div>
);
};
export default layout;

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google"; import { Geist, Geist_Mono, Inter } from "next/font/google";
import Navbar from "@/shared/widgets/navbar/components/Navbar";
import "./globals.css"; import "./globals.css";
import AuthSessionProviderWrapper from "@/shared/providers/AuthSession";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@ -30,10 +30,7 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<div className="max-w-400 mx-auto relative"> <AuthSessionProviderWrapper>{children}</AuthSessionProviderWrapper>
<Navbar />
<div className="pt-16">{children}</div>
</div>
</body> </body>
</html> </html>
); );

View File

@ -6,6 +6,7 @@
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.0.0", "@base-ui/react": "^1.0.0",
"@iconify/react": "^6.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
@ -15,8 +16,10 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^3.6.3", "shadcn": "^3.6.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"ua-parser-js": "^2.0.8",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -147,6 +150,10 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@ -657,6 +664,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
@ -941,6 +950,8 @@
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
@ -1271,6 +1282,8 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -1369,6 +1382,10 @@
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="], "typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
"ua-parser-js": ["ua-parser-js@2.0.8", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View File

@ -0,0 +1,54 @@
"use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import { cookies } from "next/headers";
export const submitProviderCallback = async (
providerName: string,
queries?: unknown,
): Promise<
BackendResponse<{
authToken: string;
}>
> => {
try {
const envKey = providerName.toUpperCase() + "_CALLBACK_URL";
const authClientCallbackUrl = (await backendFetch(
"auth/providers/" + providerName + "/callback",
)) as BackendResponse<{
callback_url: string;
}>;
if (!authClientCallbackUrl.success)
throw new Error("Failed to get auth client callback URL");
const responseProvision = (await backendFetch(
`${authClientCallbackUrl.data?.callback_url!}?callbackURI=${
process.env.APP_URL
}${process.env[envKey]}&${queries}`,
)) as BackendResponse<{
authToken: string;
}>;
if (!responseProvision.success)
throw new Error("Failed to submit provider callback");
(await cookies()).set({
name: "auth_token",
value: responseProvision.data?.authToken!,
httpOnly: true,
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: Number(process.env.SESSION_EXPIRE),
});
return responseProvision;
} catch (error) {
return {
success: false,
message: "Error submitting provider callback",
error: error,
};
}
};

View File

@ -0,0 +1,49 @@
"use client";
import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
import { submitProviderCallback } from "@/features/authCallback/actions/submitProviderCallback";
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
const AuthCallbackIndex = () => {
const { name } = useParams();
const queries = useSearchParams().toString();
const [textDescription, setTextDescription] = useState(
"We are processing your authentication.",
);
const finishOAuthFlow = (type: string) => {
setTimeout(() => {
if (!window.opener) window.location.href = "/";
window.opener.postMessage({ type: type }, window.location.origin);
window.close();
}, 1000);
};
useEffect(() => {
(async () => {
const response = await submitProviderCallback(name as string, queries);
if (response.success) {
setTextDescription("Authentication successful! Redirecting...");
finishOAuthFlow("oauth-success");
} else {
console.error("Error in authentication callback:", response);
setTextDescription("Authentication failed. Please try again.");
finishOAuthFlow("oauth-failed");
}
})();
}, []);
return (
<div className="w-full flex flex-col items-center gap-2 pt-8">
<Spinner className="size-6" />
<div className="text-center">
<h1 className="text-neutral-200 text-base">Please wait...</h1>
<p className="font-normal text-neutral-400 text-sm">
{textDescription}
</p>
</div>
</div>
);
};
export default AuthCallbackIndex;

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.0.0", "@base-ui/react": "^1.0.0",
"@iconify/react": "^6.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
@ -19,8 +20,10 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^3.6.3", "shadcn": "^3.6.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"ua-parser-js": "^2.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@ -0,0 +1,14 @@
import { createContext, useContext } from "react";
import { UserSession } from "../models/auth/validateAndDecodeJWT";
type AuthContextType = {
session: UserSession | null;
};
export const AuthContext = createContext<AuthContextType>({
session: null,
});
export function useAuth() {
return useContext(AuthContext);
}

View File

@ -1,26 +0,0 @@
interface BackendResponse<T = unknown> {
success: boolean;
message: string;
data?: T;
error?: unknown;
}
export const backendFetch = async (path: string, options: RequestInit = {}) => {
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
...options.headers,
},
cache: "default",
});
const resJson = (await res.json()) as BackendResponse;
if (!res.ok || !resJson.success) {
throw new Error(`Elysia error: ${resJson.error}`);
}
return resJson;
};

View File

@ -0,0 +1,43 @@
"use server";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
export interface BackendResponse<T = unknown> {
success: boolean;
message: string;
data?: T;
error?: unknown;
}
export const backendFetch = async (path: string, options: RequestInit = {}) => {
const userAgent = (await headers()).get("user-agent") || "";
const userIp = (await headers()).get("x-forwarded-for") || "unknown";
const ua = new UAParser(userAgent).getResult();
const clientInfo = {
os: ua.os.name ?? "unknown",
osVersion: ua.os.version ?? "unknown",
browser: ua.browser.name ?? "unknown",
browserVersion: ua.browser.version ?? "unknown",
deviceType: ua.device.type ?? "desktop",
ip: userIp,
};
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"x-client-info": JSON.stringify(clientInfo),
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
...options.headers,
},
cache: "default",
});
const resJson = (await res.json()) as BackendResponse;
if (!res.ok || !resJson.success) {
throw new Error(`Elysia error: ${resJson.error}`);
}
return resJson;
};

View File

@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"rounded-full aspect-square size-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@ -33,6 +33,9 @@ const buttonVariants = cva(
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9", "icon-lg": "size-9",
}, },
isDisabled: {
true: "bg-neutral-800 text-neutral-400 cursor-not-allowed text-xs",
},
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
@ -46,10 +49,12 @@ function Button({
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false, asChild = false,
isDisabled = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
isDisabled?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button"; const Comp = asChild ? Slot.Root : "button";
@ -58,7 +63,7 @@ function Button({
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, isDisabled, className }))}
{...props} {...props}
/> />
); );

View File

@ -0,0 +1,255 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,10 @@
import { cn } from "@/shared/libs/shadcn/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@ -0,0 +1,47 @@
"use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import { cookies } from "next/headers";
export interface UserSession {
id: string;
isAuthenticated: boolean;
validUntil: Date;
user: {
id: string;
name: string;
email: string;
username: string;
avatar: string;
birthDate: null;
bioProfile: null;
preference: {
id: string;
userId: string;
langPreference: null;
adultFiltering: string;
adultAlert: string;
videoQuality: string;
serviceDefaultId: null;
};
};
iat: number;
exp: number;
}
export const validateAndDecodeJWT = async (): Promise<UserSession | null> => {
const cookieHeader = (await cookies()).get("auth_token")?.value;
if (!cookieHeader) {
return null;
}
const res = (await backendFetch("auth/token/validate", {
method: "POST",
body: JSON.stringify({
token: cookieHeader,
}),
})) as BackendResponse<UserSession>;
return res.data!;
};

View File

@ -0,0 +1,17 @@
"use client";
import { AuthContext } from "../contexts/AuthContext";
import React from "react";
import { UserSession } from "../models/auth/validateAndDecodeJWT";
const AuthSessionProvider = ({
children,
session,
}: Readonly<{ children: React.ReactNode; session: UserSession | null }>) => {
return (
<AuthContext.Provider value={{ session: session }}>
{children}
</AuthContext.Provider>
);
};
export default AuthSessionProvider;

View File

@ -0,0 +1,17 @@
import {
UserSession,
validateAndDecodeJWT,
} from "../models/auth/validateAndDecodeJWT";
import AuthSessionProvider from "./AuthSession.client";
const AuthSessionProviderWrapper = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
let session: UserSession | null = await validateAndDecodeJWT();
return (
<AuthSessionProvider session={session}>{children}</AuthSessionProvider>
);
};
export default AuthSessionProviderWrapper;

View File

@ -2,10 +2,12 @@
import Image from "next/image"; import Image from "next/image";
import NavigationLink from "./NavigationLink"; import NavigationLink from "./NavigationLink";
import SignIn from "./SignIn"; import SignIn from "./SignIn";
import { Dialog, DialogTrigger } from "@/shared/libs/shadcn/ui/dialog"; import { useAuth } from "@/shared/contexts/AuthContext";
import PopupWrapper from "../../signin/components/PopupWrapper"; import UserProfile from "./UserProfile";
const Navbar = () => { const Navbar = () => {
const { session } = useAuth();
return ( return (
<div className="absolute z-10 top-0 w-full h-16 flex items-center justify-between"> <div className="absolute z-10 top-0 w-full h-16 flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
@ -18,9 +20,7 @@ const Navbar = () => {
/> />
<NavigationLink /> <NavigationLink />
</div> </div>
<div> <div>{session?.user ? <UserProfile /> : <SignIn />}</div>
<SignIn />
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,85 @@
import { useAuth } from "@/shared/contexts/AuthContext";
import { Avatar, AvatarImage } from "@/shared/libs/shadcn/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/libs/shadcn/ui/dropdown-menu";
import {
Bookmark,
CircleUserRound,
ClockFading,
LifeBuoy,
LogOut,
MessagesSquare,
Settings,
Webhook,
} from "lucide-react";
const UserProfile = () => {
const { session } = useAuth();
return (
<div className="h-full flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar size="lg" className="cursor-pointer">
<AvatarImage
className="rounded-md"
src={session?.user?.avatar}
alt={session?.user?.name}
/>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-38">
<DropdownMenuGroup>
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuItem>
<CircleUserRound />
My Profile
</DropdownMenuItem>
<DropdownMenuItem>
<ClockFading />
Activity
</DropdownMenuItem>
<DropdownMenuItem>
<Bookmark />
Bookmark
</DropdownMenuItem>
<DropdownMenuItem>
<Settings />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<MessagesSquare />
Forum
</DropdownMenuItem>
<DropdownMenuItem>
<LifeBuoy />
Help
</DropdownMenuItem>
<DropdownMenuItem>
<Webhook />
API
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<LogOut />
Log Out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default UserProfile;

View File

@ -0,0 +1,14 @@
"use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
export type GetALlThirdPartyAuthCallback = BackendResponse<
{
name: string;
icon: string;
req_endpoint: string;
}[]
>;
export const getAllThirdPartyAuth = async () => {
return (await backendFetch("auth/providers")) as GetALlThirdPartyAuthCallback;
};

View File

@ -0,0 +1,20 @@
"use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
interface GetOauthEndpointParams {
endpointUrl: string;
providerName: string;
}
export const getOauthEndpoint = async ({
endpointUrl,
providerName,
}: GetOauthEndpointParams) => {
const envKey = providerName.toUpperCase() + "_CALLBACK_URL";
return (await backendFetch(
`${endpointUrl}?callback=${process.env.APP_URL}${process.env[envKey]}`,
)) as BackendResponse<{
endpointUrl: string;
}>;
};

View File

@ -10,8 +10,56 @@ import {
import { Input } from "@/shared/libs/shadcn/ui/input"; import { Input } from "@/shared/libs/shadcn/ui/input";
import { Label } from "@/shared/libs/shadcn/ui/label"; import { Label } from "@/shared/libs/shadcn/ui/label";
import { Separator } from "@/shared/libs/shadcn/ui/separator"; import { Separator } from "@/shared/libs/shadcn/ui/separator";
import { useCallback, useEffect, useState } from "react";
import {
getAllThirdPartyAuth,
GetALlThirdPartyAuthCallback,
} from "../actions/getAllThirdPartyAuth";
import { Icon } from "@iconify/react";
import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
import { getOauthEndpoint } from "../actions/getOauthEndpoint";
const SignInCard = () => { const SignInCard = () => {
const [isLoading, setIsLoading] = useState(false);
const [oAuthProviders, setOAuthProviders] =
useState<GetALlThirdPartyAuthCallback | null>(null);
// Fetch available third-party auth providers on component mount
useEffect(() => {
(async () => {
const res = await getAllThirdPartyAuth();
setOAuthProviders(res);
})();
}, []);
// Open OAuth endpoint in a new popup window
const getOauthEndpointUrl = async (
providerReqEndpoint: string,
providerName: string
) => {
const res = await getOauthEndpoint({
endpointUrl: providerReqEndpoint,
providerName: providerName,
});
setIsLoading(true);
window.open(res.data?.endpointUrl, "oauthPopup", "width=500,height=600");
};
// Handle the feedback from popup window for OAuth
const handleMessage = useCallback((event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === "oauth-success") window.location.reload();
if (event.data.type === "oauth-failed") setIsLoading(false);
}, []);
useEffect(() => {
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleMessage]);
return ( return (
<DialogContent showCloseButton={false}> <DialogContent showCloseButton={false}>
<DialogHeader> <DialogHeader>
@ -26,12 +74,47 @@ const SignInCard = () => {
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input type="email" id="email" placeholder="e.g. user@example.com" /> <Input type="email" id="email" placeholder="e.g. user@example.com" />
</div> </div>
<div className="my-4 flex items-center gap-2 ">
<Separator className="flex-1 bg-neutral-700" />
<p className="text-neutral-500 text-sm">or continue with</p>
<Separator className="flex-1 bg-neutral-700" />
</div>
<div>
{oAuthProviders ? (
<div className="flex flex-col gap-1">
{oAuthProviders.data?.map((provider, index) => (
<Button
key={index}
variant="outline"
className="w-full text-neutral-300 text-xs font-normal"
disabled={isLoading}
onClick={() =>
getOauthEndpointUrl(provider.req_endpoint, provider.name)
}
>
{isLoading && <Spinner />}
<Icon icon={provider.icon} />
Continue with {provider.name}
</Button>
))}
</div>
) : (
<Button size="sm" className="w-full" variant="outline" disabled>
There are no third-party auth providers available.
</Button>
)}
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline">Cancel</Button> <Button variant="outline" disabled={isLoading}>
Cancel
</Button>
</DialogClose> </DialogClose>
<Button type="submit">Continue</Button> <Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Continue
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
); );