diff --git a/src/App.tsx b/src/App.tsx index 3219ca1..1422637 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,11 @@ import P2PCallMain from "./features/p2p-call/P2PCallMain"; import CallCreationMain from "./features/call-selection/CallCreationMain"; import CallJoinMain from "./features/call-selection/CallJoinMain"; import PendingUserMain from "./features/call-selection/PendingUserMain"; +import useValidateSession from "./hooks/useValidateSession"; +import BlockedSession from "./features/auth/BlockedSession"; export default function App() { + useValidateSession(); const dispatch = useAppDispatch(); useEffect(() => { @@ -29,6 +32,7 @@ export default function App() {

Left.

} /> + diff --git a/src/components/templates/HomeTemplate.test.tsx b/src/components/templates/HomeTemplate.test.tsx index 42d05e7..23b363a 100644 --- a/src/components/templates/HomeTemplate.test.tsx +++ b/src/components/templates/HomeTemplate.test.tsx @@ -161,4 +161,25 @@ describe("HomeTemplate", () => { ) ).toBe(null); }); + + it("does not show logout and settings buttons when user session is blocked", () => { + fullRender( + +

Hello World!

+
, + { + preloadedState: { + user: { + ...userInitialState, + uid: "123", + status: "authenticated", + isSessionBlocked: true, + }, + }, + } + ); + + expect(screen.queryByLabelText("Logout")).toBe(null); + expect(screen.queryByLabelText("Settings")).toBe(null); + }); }); diff --git a/src/components/templates/HomeTemplate.tsx b/src/components/templates/HomeTemplate.tsx index ce49ab5..0305b7e 100644 --- a/src/components/templates/HomeTemplate.tsx +++ b/src/components/templates/HomeTemplate.tsx @@ -12,7 +12,11 @@ import mainCharWhiteOutlineImg from "../../assets/main-char-white-outline.png"; import mainCharPinkOutlineImg from "../../assets/main-char-pink-outline.png"; import { useAppDispatch, useAppSelector } from "../../state"; import useAgentHelper from "../../hooks/useAgentHelper"; -import { logout, selectIsUserAuthenticated } from "../../state/user"; +import { + logout, + selectIsSessionBlocked, + selectIsUserAuthenticated, +} from "../../state/user"; import SettingsModal from "../settings/SettingsModal"; import ErrorAlert from "../basic/ErrorAlert"; import WarningAlert from "../basic/WarningAlert"; @@ -32,6 +36,7 @@ export default function HomeTemplate({ const handleLogoutClick = () => dispatch(logout()); const isAuthenticated = useAppSelector(selectIsUserAuthenticated); + const isSessionBlocked = useAppSelector(selectIsSessionBlocked); const { canRunWebRTC, isChromeBased, isFirefoxBased } = useAgentHelper(); @@ -92,7 +97,7 @@ export default function HomeTemplate({ pb: 1, }} > - {isAuthenticated && ( + {isAuthenticated && !isSessionBlocked && ( )} - - - + {!isSessionBlocked && ( + + + + )} {!canRunWebRTC() && ( diff --git a/src/features/auth/BlockedSession.test.tsx b/src/features/auth/BlockedSession.test.tsx new file mode 100644 index 0000000..6637f7e --- /dev/null +++ b/src/features/auth/BlockedSession.test.tsx @@ -0,0 +1,10 @@ +import "../../testing-helpers/mock-firestore-auth"; +import { act } from "react-dom/test-utils"; +import fullRender from "../../testing-helpers/fullRender"; +import BlockedSession from "./BlockedSession"; + +describe("BlockedSession", () => { + it("renders", async () => { + await act(() => fullRender()); + }); +}); diff --git a/src/features/auth/BlockedSession.tsx b/src/features/auth/BlockedSession.tsx new file mode 100644 index 0000000..e07c05f --- /dev/null +++ b/src/features/auth/BlockedSession.tsx @@ -0,0 +1,21 @@ +import Box from "@mui/material/Box"; +import HomeTemplate from "../../components/templates/HomeTemplate"; +import useRedirectionRule from "../../hooks/useRedirectionRule"; +import { Redirect } from "wouter"; +import ErrorAlert from "../../components/basic/ErrorAlert"; + +export default function BlockedSession() { + const goTo = useRedirectionRule(); + + if (goTo) { + return ; + } + + return ( + + + + + + ); +} diff --git a/src/hooks/useRedirectionRule.test.ts b/src/hooks/useRedirectionRule.test.ts index 27a56db..968052a 100644 --- a/src/hooks/useRedirectionRule.test.ts +++ b/src/hooks/useRedirectionRule.test.ts @@ -2,18 +2,24 @@ import { getRedirectionRule } from "./useRedirectionRule"; describe("getRedirectionRule: / (auth)", () => { it("when authenticated, go to creation", () => { - const result = getRedirectionRule({ path: "/", hasAuth: true }, {}); + const result = getRedirectionRule( + { path: "/", hasAuth: true, isSessionBlocked: false }, + {} + ); expect(result).toBe("/create"); }); it("when not authenticated, stay here for auth", () => { - const result = getRedirectionRule({ path: "/", hasAuth: false }, {}); + const result = getRedirectionRule( + { path: "/", hasAuth: false, isSessionBlocked: false }, + {} + ); expect(result).toBe(""); }); it("when autenticated and provided joining uid, then go to join page proceed to device testing", () => { const result = getRedirectionRule( - { path: "/", hasAuth: true }, + { path: "/", hasAuth: true, isSessionBlocked: false }, { joining: "123-321" } ); expect(result).toBe("/join?callUid=123-321"); @@ -21,7 +27,7 @@ describe("getRedirectionRule: / (auth)", () => { it("when autenticated and provided creating display name, then go to create page proceed to device testing", () => { const result = getRedirectionRule( - { path: "/", hasAuth: true }, + { path: "/", hasAuth: true, isSessionBlocked: false }, { creating: "Daily" } ); expect(result).toBe("/create?callDisplayName=Daily"); // TODO: names with special characters? @@ -30,28 +36,52 @@ describe("getRedirectionRule: / (auth)", () => { describe("getRedirectionRule: /create", () => { it("when not authenticated, then go back to root for login", () => { - const result = getRedirectionRule({ path: "/create", hasAuth: false }, {}); + const result = getRedirectionRule( + { path: "/create", hasAuth: false, isSessionBlocked: false }, + {} + ); expect(result).toBe("/"); }); it("when authenticated, stay here for device testing and call creation", () => { - const result = getRedirectionRule({ path: "/create", hasAuth: true }, {}); + const result = getRedirectionRule( + { path: "/create", hasAuth: true, isSessionBlocked: false }, + {} + ); expect(result).toBe(""); }); it("when authenticated and has ongoing call, go to its page", () => { const result = getRedirectionRule( - { path: "/create", hasAuth: true, ongoingCall: "123-321" }, + { + path: "/create", + hasAuth: true, + ongoingCall: "123-321", + isSessionBlocked: false, + }, {} ); expect(result).toBe("/p2p-call/123-321"); }); + + it("when session is blocked, got to blocked session page", () => { + const result = getRedirectionRule( + { + path: "/create", + hasAuth: true, + ongoingCall: "123-321", + isSessionBlocked: true, + }, + {} + ); + expect(result).toBe("/blocked-session"); + }); }); describe("getRedirectionRule: /join", () => { it("when authenticated and provided call uid, then stay here for device testing", () => { const result = getRedirectionRule( - { path: "/join", hasAuth: true }, + { path: "/join", hasAuth: true, isSessionBlocked: false }, { callUid: "123-321" } ); expect(result).toBe(""); @@ -59,35 +89,64 @@ describe("getRedirectionRule: /join", () => { it("when not authenticated but provided call uid, then go authenticate", () => { const result = getRedirectionRule( - { path: "/join", hasAuth: false }, + { path: "/join", hasAuth: false, isSessionBlocked: false }, { callUid: "123-321" } ); expect(result).toBe("/?joining=123-321"); }); it("when not authenticated nor provided call uid, then start reset entire flow (came here by mistake?)", () => { - const result = getRedirectionRule({ path: "/join", hasAuth: false }, {}); + const result = getRedirectionRule( + { path: "/join", hasAuth: false, isSessionBlocked: false }, + {} + ); expect(result).toBe("/"); }); it("when authenticated and didnt provide call uid, then stay to manually type it", () => { - const result = getRedirectionRule({ path: "/join", hasAuth: true }, {}); + const result = getRedirectionRule( + { path: "/join", hasAuth: true, isSessionBlocked: false }, + {} + ); expect(result).toBe(""); }); it("when authenticated and has pending call, go to pending user page", () => { const result = getRedirectionRule( - { path: "/join", hasAuth: true, pendingCall: "123-call-321" }, + { + path: "/join", + hasAuth: true, + pendingCall: "123-call-321", + isSessionBlocked: false, + }, {} ); expect(result).toBe("/pending"); }); + + it("when session is blocked, got to blocked session page", () => { + const result = getRedirectionRule( + { + path: "/join", + hasAuth: true, + pendingCall: "123-call-321", + isSessionBlocked: true, + }, + {} + ); + expect(result).toBe("/blocked-session"); + }); }); describe("getRedirectionRule: /join", () => { it("if has pending call, stay waiting", () => { const result = getRedirectionRule( - { path: "/pending", hasAuth: true, pendingCall: "123-call-321" }, + { + path: "/pending", + hasAuth: true, + pendingCall: "123-call-321", + isSessionBlocked: false, + }, {} ); expect(result).toBe(""); @@ -95,27 +154,51 @@ describe("getRedirectionRule: /join", () => { it("if has ongoing call, go for it", () => { const result = getRedirectionRule( - { path: "/pending", hasAuth: true, ongoingCall: "123-call-321" }, + { + path: "/pending", + hasAuth: true, + ongoingCall: "123-call-321", + isSessionBlocked: false, + }, {} ); expect(result).toBe("/p2p-call/123-call-321"); }); it("if has none, reset navigation flow", () => { - const result = getRedirectionRule({ path: "/pending", hasAuth: true }, {}); + const result = getRedirectionRule( + { path: "/pending", hasAuth: true, isSessionBlocked: false }, + {} + ); expect(result).toBe("/"); }); it("when not authenticated, reset navigation flow", () => { - const result = getRedirectionRule({ path: "/pending", hasAuth: false }, {}); + const result = getRedirectionRule( + { path: "/pending", hasAuth: false, isSessionBlocked: false }, + {} + ); expect(result).toBe("/"); }); + + it("when session is blocked, got to blocked session page", () => { + const result = getRedirectionRule( + { path: "/pending", hasAuth: true, isSessionBlocked: true }, + {} + ); + expect(result).toBe("/blocked-session"); + }); }); describe("getRedirectionRule: /p2p-call", () => { it("when authenticated with ongoing call, then stay here", () => { const result = getRedirectionRule( - { path: "/p2p-call/123-321", hasAuth: true, ongoingCall: "123-321" }, + { + path: "/p2p-call/123-321", + hasAuth: true, + ongoingCall: "123-321", + isSessionBlocked: false, + }, {} ); expect(result).toBe(""); @@ -123,7 +206,7 @@ describe("getRedirectionRule: /p2p-call", () => { it("when authenticated without ongoing call, go to a page saying good bye", () => { const result = getRedirectionRule( - { path: "/p2p-call/123-321", hasAuth: true }, + { path: "/p2p-call/123-321", hasAuth: true, isSessionBlocked: false }, {} ); // expect(result).toBe("/left"); @@ -133,9 +216,17 @@ describe("getRedirectionRule: /p2p-call", () => { it("when not authenticated, be forced to reset flow", () => { // TODO: retrieve attempt call uid and convert into "/?joining=" flow const result = getRedirectionRule( - { path: "/p2p-call/123-321", hasAuth: false }, + { path: "/p2p-call/123-321", hasAuth: false, isSessionBlocked: false }, {} ); expect(result).toBe("/"); }); + + it("when session is blocked, got to blocked session page", () => { + const result = getRedirectionRule( + { path: "/p2p-call/123-321", hasAuth: false, isSessionBlocked: true }, + {} + ); + expect(result).toBe("/blocked-session"); + }); }); diff --git a/src/hooks/useRedirectionRule.ts b/src/hooks/useRedirectionRule.ts index 041e433..688231e 100644 --- a/src/hooks/useRedirectionRule.ts +++ b/src/hooks/useRedirectionRule.ts @@ -1,6 +1,9 @@ import { useLocation } from "wouter"; import { useAppSelector } from "../state"; -import { selectIsUserAuthenticated } from "../state/user"; +import { + selectIsSessionBlocked, + selectIsUserAuthenticated, +} from "../state/user"; import { selectCallUid, selectCallUserStatus } from "../state/call"; export default function useRedirectionRule(): string { @@ -8,6 +11,7 @@ export default function useRedirectionRule(): string { const callUserStatus = useAppSelector(selectCallUserStatus); const callUid = useAppSelector(selectCallUid); + const isSessionBlocked = useAppSelector(selectIsSessionBlocked); const [location] = useLocation(); @@ -21,6 +25,7 @@ export default function useRedirectionRule(): string { path: location, pendingCall: callUserStatus === "pending-user" ? callUid : "", ongoingCall: callUserStatus === "participant" ? callUid : "", + isSessionBlocked, }; const goTo = getRedirectionRule(context, search); @@ -30,15 +35,32 @@ export default function useRedirectionRule(): string { export interface RedirectionContext { path: string; hasAuth: boolean; + isSessionBlocked: boolean; pendingCall?: string; ongoingCall?: string; } export function getRedirectionRule( - { path, hasAuth, pendingCall, ongoingCall }: RedirectionContext, + { + path, + hasAuth, + pendingCall, + ongoingCall, + isSessionBlocked, + }: RedirectionContext, search: Record ): string { + if (path === "/blocked-session") { + if (!isSessionBlocked) { + return "/"; + } + } + if (path === "/") { + if (isSessionBlocked) { + return "/blocked-session"; + } + if (hasAuth && search.joining) { return `/join?callUid=${search.joining}`; } @@ -53,6 +75,10 @@ export function getRedirectionRule( } if (path === "/create") { + if (isSessionBlocked) { + return "/blocked-session"; + } + if (hasAuth && ongoingCall) { return `/p2p-call/${ongoingCall}`; } @@ -63,6 +89,10 @@ export function getRedirectionRule( } if (path === "/join") { + if (isSessionBlocked) { + return "/blocked-session"; + } + if (pendingCall && hasAuth) { return "/pending"; } @@ -81,6 +111,10 @@ export function getRedirectionRule( } if (path === "/pending") { + if (isSessionBlocked) { + return "/blocked-session"; + } + if (pendingCall) { return ""; } @@ -93,6 +127,10 @@ export function getRedirectionRule( } if (path.startsWith("/p2p-call")) { + if (isSessionBlocked) { + return "/blocked-session"; + } + if (hasAuth && !ongoingCall) { return "/create"; // return "/left"; diff --git a/src/hooks/useValidateSession.ts b/src/hooks/useValidateSession.ts new file mode 100644 index 0000000..8d6b1d1 --- /dev/null +++ b/src/hooks/useValidateSession.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "../state"; +import { + selectUserDeviceUuid, + selectUserUid, + setSessionActive, + setSessionBlocked, +} from "../state/user"; +import firestoreAuth from "../services/firestore-auth"; + +export default function useValidateSession() { + const dispatch = useAppDispatch(); + + const userUid = useAppSelector(selectUserUid); + const userDeviceUuid = useAppSelector(selectUserDeviceUuid); + + useEffect(() => { + if (!userDeviceUuid) { + return () => null; + } + + return firestoreAuth.listenUserSession(userUid, (session) => { + if (!session || !session?.deviceUuid) { + return; + } + + if (session.deviceUuid === userDeviceUuid) { + dispatch(setSessionActive()); + return; + } + + dispatch(setSessionBlocked()); + }); + }, [dispatch, userDeviceUuid, userUid]); +} diff --git a/src/services/firestore-auth.test.ts b/src/services/firestore-auth.test.ts index ca3999b..605e973 100644 --- a/src/services/firestore-auth.test.ts +++ b/src/services/firestore-auth.test.ts @@ -1,17 +1,41 @@ import "../testing-helpers/mock-firebase-auth"; import firestoreAuth from "./firestore-auth"; import * as firebaseAuth from "firebase/auth"; +import { doc, setDoc, onSnapshot } from "firebase/firestore"; +import { v4 as uuidv4 } from "uuid"; + +jest.mock("firebase/firestore", () => ({ + doc: jest.fn(), + setDoc: jest.fn(), + onSnapshot: jest.fn(), + collection: jest.fn(), + query: jest.fn(), +})); + +jest.mock("uuid", () => ({ + v4: jest.fn(), +})); + +jest.mock("./firestore-connection", () => ({ + db: jest.fn(), +})); describe("firestoreAuth", () => { + beforeEach(() => { + (uuidv4 as jest.Mock).mockReturnValueOnce("7390-4d50-91f3-abcdef1e0d50"); + }); it("should successfully log in and return user information", async () => { jest.spyOn(firebaseAuth, "getAuth"); jest.spyOn(firebaseAuth, "getRedirectResult"); + (doc as jest.Mock).mockImplementationOnce((_, path) => path); + (setDoc as jest.Mock).mockResolvedValueOnce(Promise.resolve(null)); await firestoreAuth.login(); const user = await firestoreAuth.loadUser(); expect(user).toEqual({ uid: "abc123def456", displayName: "Jane Doe", email: "jane@example.com", + deviceUuid: "7390-4d50-91f3-abcdef1e0d50", }); }); @@ -51,3 +75,31 @@ describe("firestoreAuth", () => { await expect(firestoreAuth.logout()).resolves.not.toThrow(); }); }); + +describe("listenUserSession", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it("listenUserSession will notify the callback on each snapshot invocation", async () => { + const mockUserUid = "zJTvoYDGr9PuN1z69vheO0b4iWF2"; + (onSnapshot as jest.Mock).mockImplementation((_, onNext) => + onNext({ + forEach: Array.prototype.forEach.bind([ + { + id: mockUserUid, + data: () => ({ + deviceUuid: "6db9ad2e-19f9-4a85-b383-7731e347b7d0", + }), + }, + ]), + }) + ); + + const mockCallback = jest.fn(); + firestoreAuth.listenUserSession(mockUserUid, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith({ + deviceUuid: "6db9ad2e-19f9-4a85-b383-7731e347b7d0", + }); + }); +}); diff --git a/src/services/firestore-auth.ts b/src/services/firestore-auth.ts index 81added..bb4a332 100644 --- a/src/services/firestore-auth.ts +++ b/src/services/firestore-auth.ts @@ -8,13 +8,24 @@ import { signInWithRedirect, signOut, } from "firebase/auth"; -import { googleAuthProvider } from "./firestore-connection"; +import { v4 as uuidv4 } from "uuid"; +import { db, googleAuthProvider } from "./firestore-connection"; import type { User } from "../webrtc"; +import { + DocumentData, + Unsubscribe, + collection, + doc, + onSnapshot, + query, + setDoc, +} from "firebase/firestore"; const firestoreAuth = { loadUser, login, logout, + listenUserSession, }; export default firestoreAuth; @@ -24,7 +35,12 @@ async function loadUser(): Promise { return new Promise((resolve, reject) => { const handleResult = (user: FirebaseUser | null) => { if (!user) { - const empty: User = { uid: "", displayName: "", email: "" }; + const empty: User = { + uid: "", + displayName: "", + email: "", + deviceUuid: "", + }; resolve(empty); return; } @@ -34,10 +50,17 @@ async function loadUser(): Promise { return; } + const deviceUuid = uuidv4(); + // Create or update the user session in collection "session" + setDoc(doc(db, `session/${user.uid}`), { + deviceUuid, + }); + resolve({ uid: user.uid, displayName: user.displayName ?? user.email, email: user.email, + deviceUuid, }); }; @@ -67,3 +90,25 @@ async function login(): Promise { async function logout() { signOut(getAuth()); } + +interface SessionResult { + userUid: string; + deviceUuid: string; +} + +function listenUserSession( + userUid: string, + callback: (result?: Omit) => void +): Unsubscribe { + return onSnapshot(query(collection(db, `session`)), (querySnapshot) => { + const sessions: SessionResult[] = []; + + querySnapshot.forEach((doc: DocumentData) => { + sessions.push({ ...doc.data(), userUid: doc.id } as SessionResult); + }); + + const session = sessions.find((s) => s.userUid === userUid); + + callback({ deviceUuid: session?.deviceUuid || "" }); + }); +} diff --git a/src/state/user.ts b/src/state/user.ts index c734310..5e92bf4 100644 --- a/src/state/user.ts +++ b/src/state/user.ts @@ -10,6 +10,7 @@ type UserStatus = "idle" | "pending" | "authenticated" | "error"; export interface UserState extends User { status: UserStatus; errorMessage: string; + isSessionBlocked: boolean; } export const userInitialState: UserState = { @@ -17,19 +18,29 @@ export const userInitialState: UserState = { displayName: "", email: "", status: "idle", + deviceUuid: "", errorMessage: "", + isSessionBlocked: false, }; export const userSlice = createSlice({ name: "user", initialState: userInitialState, - reducers: {}, + reducers: { + setSessionBlocked: (state) => { + state.isSessionBlocked = true; + }, + setSessionActive: (state) => { + state.isSessionBlocked = false; + }, + }, extraReducers: (builder) => { builder.addCase(login.pending, (state) => { state.uid = ""; state.displayName = ""; state.email = ""; state.status = "pending"; + state.deviceUuid = ""; state.errorMessage = ""; }); @@ -38,7 +49,8 @@ export const userSlice = createSlice({ state.displayName = action.payload.displayName || ""; state.email = action.payload.email || ""; state.status = action.payload.uid ? "authenticated" : "idle"; - state.errorMessage = ""; + state.deviceUuid = action.payload.deviceUuid; + state.errorMessage = state.errorMessage = ""; }); builder.addCase(login.rejected, (state, action) => { @@ -46,6 +58,7 @@ export const userSlice = createSlice({ state.displayName = ""; state.email = ""; state.status = "error"; + state.deviceUuid = ""; state.errorMessage = action.error.message || "Unknown error."; }); @@ -87,10 +100,16 @@ export const selectUserDisplayName = (state: RootState) => state.user.displayName; export const selectUserUid = (state: RootState) => state.user.uid; +export const selectUserDeviceUuid = (state: RootState) => state.user.deviceUuid; export const selectIsUserPendingAuthentication = (state: RootState) => state.user.status === "pending"; export const selectIsUserAuthenticated = (state: RootState) => Boolean(state.user.status === "authenticated" && state.user.uid); +export const selectIsSessionBlocked = (state: RootState) => + state.user.isSessionBlocked; + +export const { setSessionBlocked, setSessionActive } = userSlice.actions; + export default userSlice.reducer; diff --git a/src/testing-helpers/call-fixtures.ts b/src/testing-helpers/call-fixtures.ts index d61d8eb..b9e6c84 100644 --- a/src/testing-helpers/call-fixtures.ts +++ b/src/testing-helpers/call-fixtures.ts @@ -6,6 +6,7 @@ export function createUser(overrideValues: Partial = {}): User { uid: uuidv4(), displayName: getRandomString(), email: getRandomEmail(), + deviceUuid: uuidv4(), }; return { ...defaultValues, ...overrideValues }; diff --git a/src/webrtc/index.ts b/src/webrtc/index.ts index 3052232..6eeafa2 100644 --- a/src/webrtc/index.ts +++ b/src/webrtc/index.ts @@ -21,6 +21,7 @@ export default { export interface User extends UniqueEntity { displayName: string; email: string; + deviceUuid: string; } export interface Call extends UniqueEntity {