diff --git a/SSO-Frontend-Microsoft.md b/SSO-Frontend-Microsoft.md new file mode 100644 index 0000000..8eed47c --- /dev/null +++ b/SSO-Frontend-Microsoft.md @@ -0,0 +1,93 @@ +# Frontend Guide - Microsoft SSO (Entra ID) + +## 1) Muc tieu +Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb. + +## 2) Redirect URI da dang ky +Redirect URI cho Microsoft app: + +https://comp.soict.io/api/auth/sso/callback + +Luu y: +- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path). +- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch. + +## 3) Endpoint frontend can goi +Backend route SSO Microsoft (legacy route): + +- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL} +- POST /api/auth/sso/exchange + +Route alias cung ho tro: +- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL} +- POST /api/sso/exchange + +## 4) Login flow cho frontend +1. User bam nut "Login with Microsoft". +2. Frontend redirect browser den: + /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL} +3. Backend redirect sang Microsoft login page. +4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI: + https://comp.soict.io/api/auth/sso/callback +5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query: + ?code={ONE_TIME_CODE} +6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo. +7. Frontend luu token va thong tin user, dieu huong vao app. + +## 5) API exchange chi tiet +Method: POST +Path: /api/auth/sso/exchange +Content-Type: application/json + +Request body: +{ + "code": "" +} + +Success response (HTTP 200): +{ + "token": "", + "name": "Nguyen Van A", + "username": "user@hust.edu.vn", + "access": [1, 2, 3], + "role": { + "roleName": "Pending", + "priority": 99 + } +} + +## 6) Error handling goi y cho frontend +- 400 BadRequest: code thieu hoac token khong hop le. +- 401 Unauthorized: code het han / da dung / khong hop le. +- 404 NotFound: user khong ton tai sau khi exchange. + +One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan. +Neu exchange that bai, can yeu cau user login lai. + +## 7) Mau frontend pseudo-code +const returnUrl = window.location.origin + "/sso/complete"; +window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`; + +// tai /sso/complete +const code = new URLSearchParams(window.location.search).get("code"); +if (code) { + const res = await fetch("/api/auth/sso/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }) + }); + + if (!res.ok) { + // hien thi thong bao loi va cho user thu lai + } else { + const data = await res.json(); + // save data.token, data.username, data.role, data.access + } +} + +## 8) Checklist truoc khi UAT +- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback +- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete) +- Frontend parse duoc query code +- Frontend goi exchange ngay sau khi nhan code +- Frontend xu ly day du HTTP 400/401/404 diff --git a/src/components/forms/login-form.tsx b/src/components/forms/login-form.tsx index 57679aa..f1104a7 100644 --- a/src/components/forms/login-form.tsx +++ b/src/components/forms/login-form.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { LoginResquest } from "@/types/auth"; import { useMutation } from "@tanstack/react-query"; -import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service"; +import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service"; import { useState } from "react"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { Route } from "@/routes/(auth)/login"; @@ -46,12 +46,22 @@ export function LoginForm({ className }: React.ComponentProps<"form">) { const handleGoogleLogin = () => { const returnUrl = new URL("/oauth/callback", window.location.origin); + returnUrl.searchParams.set("provider", "google"); if (search.redirect) { returnUrl.searchParams.set("redirect", search.redirect); } window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString())); }; + const handleMicrosoftLogin = () => { + const returnUrl = new URL("/oauth/callback", window.location.origin); + returnUrl.searchParams.set("provider", "azuread"); + if (search.redirect) { + returnUrl.searchParams.set("redirect", search.redirect); + } + window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString())); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setErrorMessage(null); @@ -133,6 +143,15 @@ export function LoginForm({ className }: React.ComponentProps<"form">) { Đăng nhập với Google + diff --git a/src/components/forms/upload-file-form.tsx b/src/components/forms/upload-file-form.tsx index 9a4ff19..00f4670 100644 --- a/src/components/forms/upload-file-form.tsx +++ b/src/components/forms/upload-file-form.tsx @@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr const [isDone, setIsDone] = useState(false); // Match server allowed extensions - const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"]; + const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"]; const isFileValid = (file: File) => { const fileName = file.name.toLowerCase(); return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext)); diff --git a/src/routes/(auth)/oauth/callback/index.tsx b/src/routes/(auth)/oauth/callback/index.tsx index b8750c5..f62e890 100644 --- a/src/routes/(auth)/oauth/callback/index.tsx +++ b/src/routes/(auth)/oauth/callback/index.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button"; import { LoaderCircle } from "lucide-react"; import axios from "axios"; -import { exchangeOAuthCode } from "@/services/auth.service"; +import { exchangeCodeByProvider } from "@/services/auth.service"; import type { LoginResponse } from "@/types/auth"; const inFlightExchanges = new Map>(); @@ -18,18 +18,21 @@ export const Route = createFileRoute("/(auth)/oauth/callback/")({ function OAuthCallbackPage() { const auth = useAuth(); const navigate = useNavigate(); - const search = Route.useSearch() as { code?: string; redirect?: string }; + const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string }; const [errorMessage, setErrorMessage] = useState(null); const [isExchanging, setIsExchanging] = useState(false); useEffect(() => { const code = search.code; + const provider = (search.provider || "").toLowerCase(); if (!code) { setErrorMessage("OAuth code is missing."); return; } - if (consumedCodes.has(code)) { + const exchangeId = `${provider || "auto"}:${code}`; + + if (consumedCodes.has(exchangeId)) { setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại."); return; } @@ -38,10 +41,10 @@ function OAuthCallbackPage() { setIsExchanging(true); let cancelled = false; - let exchangePromise = inFlightExchanges.get(code); + let exchangePromise = inFlightExchanges.get(exchangeId); if (!exchangePromise) { - exchangePromise = exchangeOAuthCode(code); - inFlightExchanges.set(code, exchangePromise); + exchangePromise = exchangeCodeByProvider(code, provider); + inFlightExchanges.set(exchangeId, exchangePromise); } exchangePromise @@ -53,7 +56,7 @@ function OAuthCallbackPage() { return; } - consumedCodes.add(code); + consumedCodes.add(exchangeId); localStorage.setItem("token", data.token); localStorage.setItem("username", data.username || ""); @@ -74,14 +77,14 @@ function OAuthCallbackPage() { if (cancelled) return; if (axios.isAxiosError(error) && error.response?.status === 401) { - consumedCodes.add(code); + consumedCodes.add(exchangeId); setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại."); return; } setErrorMessage("OAuth exchange failed."); }) .finally(() => { - inFlightExchanges.delete(code); + inFlightExchanges.delete(exchangeId); if (!cancelled) { setIsExchanging(false); } @@ -90,7 +93,7 @@ function OAuthCallbackPage() { return () => { cancelled = true; }; - }, [auth, navigate, search.code, search.redirect]); + }, [auth, navigate, search.code, search.provider, search.redirect]); return (
diff --git a/src/routes/_auth/rooms/$roomName/folder-status/index.tsx b/src/routes/_auth/rooms/$roomName/folder-status/index.tsx index d7fca40..8acee7a 100644 --- a/src/routes/_auth/rooms/$roomName/folder-status/index.tsx +++ b/src/routes/_auth/rooms/$roomName/folder-status/index.tsx @@ -36,6 +36,25 @@ function RouteComponent() { roomName as string, ); + const sortedFolderStatusList = useMemo(() => { + return [...(folderStatusList ?? [])].sort((a, b) => { + const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName; + const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName; + + if (aRoom || bRoom) { + return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", { + numeric: true, + sensitivity: "base", + }); + } + + return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", { + numeric: true, + sensitivity: "base", + }); + }); + }, [folderStatusList]); + const columnHelper = createColumnHelper(); const columns = useMemo( @@ -80,7 +99,7 @@ function RouteComponent() { ); const table = useReactTable({ - data: folderStatusList ?? [], + data: sortedFolderStatusList, columns, getCoreRowModel: getCoreRowModel(), }); @@ -88,7 +107,7 @@ function RouteComponent() { return ( navigate({ to: "/rooms/$roomName/", params: { roomName } } as any) diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 8988992..87b4c49 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -35,6 +35,16 @@ export function buildGoogleOAuthLoginUrl(returnUrl: string): string { return buildOAuthLoginUrl("google", returnUrl); } +/** + * Build Microsoft SSO login URL (legacy endpoint) + * @param returnUrl - FE callback url + */ +export function buildMicrosoftSsoLoginUrl(returnUrl: string): string { + const base = API_ENDPOINTS.AUTH.SSO_LOGIN; + const encoded = encodeURIComponent(returnUrl); + return `${base}?returnUrl=${encoded}`; +} + /** * Exchange one-time OAuth code for login payload * @param code - one-time code @@ -75,6 +85,27 @@ export async function exchangeSsoCode(code: string): Promise { return exchangeOAuthCode(code); } +/** + * Exchange one-time code by provider without breaking existing flows. + * - azuread/microsoft: force legacy SSO exchange endpoint + * - default: use OAuth exchange flow + */ +export async function exchangeCodeByProvider( + code: string, + provider?: string +): Promise { + const providerKey = (provider || "").toLowerCase(); + if (providerKey === "microsoft" || providerKey === "azuread") { + const response = await rawAxios.post( + API_ENDPOINTS.AUTH.SSO_EXCHANGE, + { code } + ); + return response.data; + } + + return exchangeOAuthCode(code); +} + /** * Đăng xuất */ diff --git a/src/types/user-profile.ts b/src/types/user-profile.ts index 229efc4..4af395c 100644 --- a/src/types/user-profile.ts +++ b/src/types/user-profile.ts @@ -4,7 +4,7 @@ export type UserProfile = { name: string; role: string; roleId: number; - accessRooms: number[]; + accessRooms: string[]; createdAt?: string | null; createdBy?: string | null; updatedAt?: string | null;