From 47b0ecea8dd31693450dace33c5921d533cb0f6f Mon Sep 17 00:00:00 2001 From: Rapha Date: Tue, 25 Aug 2026 17:29:53 +0200 Subject: [PATCH] feat: add user deletion check and handle logout for deleted accounts Signed-off-by: Rapha --- .../src/integration/db.integration.test.ts | 57 +- .../routes/basic-auth.integration.test.ts | 33 +- apps/backend/src/middleware/basic-auth.ts | 6 +- .../20260830094426_check_user_deleted.sql | 497 ++++++++++++++++++ apps/backend/supabase/schemas/schema.sql | 83 +-- .../api/auth/get-is-user-banned-or-deleted.ts | 13 + .../src/api/auth/get-is-user-banned.ts | 11 - .../src/api/session/handle-session-change.ts | 2 +- .../src/hooks/use-session-redirect.tsx | 18 +- apps/frontend/src/store/auth-store.ts | 25 +- apps/frontend/tests/e2e/auth.spec.ts | 33 ++ libs/db-schema/index.ts | 2 +- 12 files changed, 695 insertions(+), 85 deletions(-) create mode 100644 apps/backend/supabase/migrations/20260830094426_check_user_deleted.sql create mode 100644 apps/frontend/src/api/auth/get-is-user-banned-or-deleted.ts delete mode 100644 apps/frontend/src/api/auth/get-is-user-banned.ts diff --git a/apps/backend/src/integration/db.integration.test.ts b/apps/backend/src/integration/db.integration.test.ts index ac18b066b..c6b2695aa 100644 --- a/apps/backend/src/integration/db.integration.test.ts +++ b/apps/backend/src/integration/db.integration.test.ts @@ -70,7 +70,7 @@ describe("Integration tests for DB", async () => { }); }); - describe("is_current_user_banned()", () => { + describe("is_current_user_banned_or_deleted()", () => { const givenEmail = "ban-rpc-test@ts.berlin"; const givenPassword = "SecurePassword123!"; let userId: string = ""; @@ -101,12 +101,12 @@ describe("Integration tests for DB", async () => { password: givenPassword, }); - const { data: isBanned, error } = await supabaseAnonClient.rpc( - "is_current_user_banned", + const { data: isBannedOrDeleted, error } = await supabaseAnonClient.rpc( + "is_current_user_banned_or_deleted", ); expect(error).toBeNull(); - expect(isBanned).toBe(false); + expect(isBannedOrDeleted).toBe(false); }); it("should return true for banned user then return false after unbanning the user", async () => { @@ -121,11 +121,11 @@ describe("Integration tests for DB", async () => { }); expect(banError).toBeNull(); - const { data: isBanned1, error: isBannedError } = - await supabaseAnonClient.rpc("is_current_user_banned"); + const { data: isBannedOrDeleted1, error: isBannedOrDeletedError1 } = + await supabaseAnonClient.rpc("is_current_user_banned_or_deleted"); - expect(isBannedError).toBeNull(); - expect(isBanned1).toBe(true); + expect(isBannedOrDeletedError1).toBeNull(); + expect(isBannedOrDeleted1).toBe(true); const { error: unbanError } = await serviceRoleDbClient.auth.admin.updateUserById(userId, { @@ -133,12 +133,45 @@ describe("Integration tests for DB", async () => { }); expect(unbanError).toBeNull(); - const { data: isBanned2, error } = await supabaseAnonClient.rpc( - "is_current_user_banned", + const { data: isBannedOrDeleted2, error: isBannedOrDeletedError2 } = + await supabaseAnonClient.rpc("is_current_user_banned_or_deleted"); + + expect(isBannedOrDeletedError2).toBeNull(); + expect(isBannedOrDeleted2).toBe(false); + }); + + it("should return true for a deleted user with a still-valid session", async () => { + // Dedicated throwaway user so we don't disturb the shared test user + const deletedUserEmail = "deleted-rpc-test@ts.berlin"; + const { data: createData, error: createError } = + await serviceRoleDbClient.auth.admin.createUser({ + email: deletedUserEmail, + password: givenPassword, + email_confirm: true, + }); + expect(createError).toBeNull(); + const deletedUserId = createData.user?.id ?? ""; + expect(deletedUserId).not.toBe(""); + + // Sign in to obtain a valid JWT, then delete the underlying auth.users row. + // The JWT is still valid, so auth.uid() resolves but no user row exists. + const { error: signInError } = + await supabaseAnonClient.auth.signInWithPassword({ + email: deletedUserEmail, + password: givenPassword, + }); + expect(signInError).toBeNull(); + + const { error: deleteError } = + await serviceRoleDbClient.auth.admin.deleteUser(deletedUserId); + expect(deleteError).toBeNull(); + + const { data: isBannedOrDeleted, error } = await supabaseAnonClient.rpc( + "is_current_user_banned_or_deleted", ); expect(error).toBeNull(); - expect(isBanned2).toBe(false); + expect(isBannedOrDeleted).toBe(true); }); }); @@ -863,7 +896,7 @@ describe("Integration tests for DB", async () => { await supabaseAnonClient.rpc("delete_user"); expect(deleteError.message).toBe( - "Permission denied: banned users may not delete their account", + "Permission denied: banned or deleted users may not delete their account", ); const { data, error: selectError } = diff --git a/apps/backend/src/integration/routes/basic-auth.integration.test.ts b/apps/backend/src/integration/routes/basic-auth.integration.test.ts index 178c4ad90..fc2c63c6b 100644 --- a/apps/backend/src/integration/routes/basic-auth.integration.test.ts +++ b/apps/backend/src/integration/routes/basic-auth.integration.test.ts @@ -51,9 +51,15 @@ describe("basic auth middleware", () => { }); afterEach(async () => { + // The "deleted user" test below deletes the user itself, so this + // cleanup tolerates only that specific case — any other error should + // still fail the test. const { error: deleteUserError } = await serviceRoleDbClient.auth.admin.deleteUser(session.user.id); - expect(deleteUserError).toBeNull(); + + if (deleteUserError && deleteUserError.code !== "user_not_found") { + throw deleteUserError; + } }); it("GET / should return a 404 Not Found response with valid session", async () => { @@ -96,5 +102,30 @@ describe("basic auth middleware", () => { expect(response.status).toBe(401); expect(actualResponse).toStrictEqual(expectedResponse); }); + + it("GET / should return a 401 Unauthorized response with valid session but deleted user", async () => { + const { error } = await serviceRoleDbClient.auth.admin.deleteUser( + session.user.id, + ); + + expect(error).toBeNull(); + + // Reuse the pre-delete access token — simulates a still-valid token + // for an account that has since been deleted. + const response = await app.request("http://localhost:3000/", { + method: "GET", + headers: new Headers({ + authorization: `Bearer ${session.access_token}`, + }), + }); + + const actualResponse = await response.json(); + const expectedResponse = { + error: "Unauthorized: Invalid or expired session", + }; + + expect(response.status).toBe(401); + expect(actualResponse).toStrictEqual(expectedResponse); + }); }); }); diff --git a/apps/backend/src/middleware/basic-auth.ts b/apps/backend/src/middleware/basic-auth.ts index 9deb05f24..2c101654b 100644 --- a/apps/backend/src/middleware/basic-auth.ts +++ b/apps/backend/src/middleware/basic-auth.ts @@ -36,8 +36,8 @@ const basicAuth = createMiddleware(async (c: Context, next: Next) => { } const userClient = createUserScopedDbClient(token); - const { data: isUserBanned, error } = await userClient.rpc( - "is_current_user_banned", + const { data: isUserBannedOrDeleted, error } = await userClient.rpc( + "is_current_user_banned_or_deleted", ); if (error) { @@ -45,7 +45,7 @@ const basicAuth = createMiddleware(async (c: Context, next: Next) => { return c.json({ error: "Unauthorized: Invalid or expired session" }, 401); } - if (isUserBanned) { + if (isUserBannedOrDeleted) { captureError(new Error("User account was banned")); return c.json({ error: "Unauthorized: Invalid or expired session" }, 401); } diff --git a/apps/backend/supabase/migrations/20260830094426_check_user_deleted.sql b/apps/backend/supabase/migrations/20260830094426_check_user_deleted.sql new file mode 100644 index 000000000..3b3975ef1 --- /dev/null +++ b/apps/backend/supabase/migrations/20260830094426_check_user_deleted.sql @@ -0,0 +1,497 @@ +-- auto-generated via `supabase db diff --schema public` +DROP POLICY "access_group_members_select" ON "public"."access_group_members"; + +DROP POLICY "Allow authenticated users to CRUD their own chat_messages" ON "public"."chat_messages"; + +DROP POLICY "Allow authenticated users to CRUD their own chats" ON "public"."chats"; + +DROP POLICY "Allow authenticated users to access own or public document_chun" ON "public"."document_chunks"; + +DROP POLICY "Allow authenticated users to CRUD their own document_folders" ON "public"."document_folders"; + +DROP POLICY "Allow authenticated users to access own or public document_summ" ON "public"."document_summaries"; + +DROP POLICY "Allow authenticated users to insert documents" ON "public"."documents"; + +DROP POLICY "Allow authenticated users to read documents" ON "public"."documents"; + +DROP POLICY "Allow authenticated users to update documents" ON "public"."documents"; + +DROP POLICY "Allow owners to delete documents and admins to delete base know" ON "public"."documents"; + +DROP POLICY "Allow authenticated users to CRUD their own rows" ON "public"."favorite_documents"; + +DROP POLICY "Allow authenticated users to access own profile" ON "public"."profiles"; + +DROP POLICY "Users can insert their own profile." ON "public"."profiles"; + +DROP POLICY "Users can update own profile." ON "public"."profiles"; + +DROP POLICY "Users can insert their own hidden default docs" ON "public"."user_hidden_default_documents"; + +DROP POLICY "Users can view their own hidden default docs" ON "public"."user_hidden_default_documents"; + +DROP POLICY "Authenticated users can upload a new document." ON "storage"."objects"; + +DROP POLICY "Users can only select their own documents." ON "storage"."objects"; + +DROP POLICY "Users can update their own document." ON "storage"."objects"; + +DROP POLICY "Users can delete objects where their user ID is in the path" ON "storage"."objects"; + +DROP FUNCTION if EXISTS "public"."is_current_user_banned" (); + +SET + check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.is_current_user_banned_or_deleted () RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER +SET + search_path TO '' AS $function$ +SELECT + -- Treat a deleted user (valid pre-deletion session, but no row) as banned + NOT EXISTS ( + SELECT 1 FROM auth.users u WHERE u.id = auth.uid() + ) + OR EXISTS ( + SELECT 1 + FROM auth.users u + WHERE u.id = auth.uid() + AND u.banned_until IS NOT NULL + AND u.banned_until > now() + ); +$function$; + +CREATE OR REPLACE FUNCTION public.delete_user () RETURNS void LANGUAGE plpgsql SECURITY DEFINER +SET + search_path TO '' +SET + statement_timeout TO '60000' AS $function$ +BEGIN + IF public.is_current_user_banned_or_deleted() THEN + RAISE EXCEPTION 'Permission denied: banned or deleted users may not delete their account'; +END IF; + +DELETE FROM auth.users WHERE id = auth.uid(); +END; +$function$; + +CREATE OR REPLACE FUNCTION public.is_application_admin () RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER +SET + search_path TO '' AS $function$ +SELECT + EXISTS (SELECT 1 FROM public.application_admins WHERE user_id = auth.uid()) + AND NOT (SELECT public.is_current_user_banned_or_deleted()); +$function$; + +CREATE POLICY "access_group_members_select" ON "public"."access_group_members" AS permissive FOR +SELECT + TO public USING ( + ( + public.is_application_admin () + OR ( + ( + user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ) + ); + +CREATE POLICY "Allow authenticated users to CRUD their own chat_messages" ON "public"."chat_messages" AS permissive FOR ALL TO authenticated USING ( + ( + ( + EXISTS ( + SELECT + 1 + FROM + public.chats + WHERE + ( + (chats.id = chat_messages.chat_id) + AND ( + chats.user_id = ( + SELECT + auth.uid () AS uid + ) + ) + ) + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + EXISTS ( + SELECT + 1 + FROM + public.chats + WHERE + ( + (chats.id = chat_messages.chat_id) + AND ( + chats.user_id = ( + SELECT + auth.uid () AS uid + ) + ) + ) + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow authenticated users to CRUD their own chats" ON "public"."chats" AS permissive FOR ALL TO authenticated USING ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow authenticated users to access own or public document_chun" ON "public"."document_chunks" AS permissive FOR ALL TO authenticated USING ( + ( + ( + (owned_by_user_id IS NULL) + OR ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + OR ( + public.is_application_admin () + AND (owned_by_user_id IS NULL) + ) + ) + ); + +CREATE POLICY "Allow authenticated users to CRUD their own document_folders" ON "public"."document_folders" AS permissive FOR ALL TO authenticated USING ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow authenticated users to access own or public document_summ" ON "public"."document_summaries" AS permissive FOR ALL TO authenticated USING ( + ( + ( + (owned_by_user_id IS NULL) + OR ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + OR ( + public.is_application_admin () + AND (owned_by_user_id IS NULL) + ) + ) + ); + +CREATE POLICY "Allow authenticated users to insert documents" ON "public"."documents" AS permissive FOR insert TO authenticated +WITH + CHECK ( + ( + ( + ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + OR ( + public.is_application_admin () + AND (owned_by_user_id IS NULL) + ) + ) + ); + +CREATE POLICY "Allow authenticated users to read documents" ON "public"."documents" AS permissive FOR +SELECT + TO authenticated USING ( + ( + ( + (owned_by_user_id IS NULL) + OR ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow authenticated users to update documents" ON "public"."documents" AS permissive +FOR UPDATE + TO authenticated USING ( + ( + ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow owners to delete documents and admins to delete base know" ON "public"."documents" AS permissive FOR delete TO authenticated USING ( + ( + ( + ( + ( + owned_by_user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + OR ( + public.is_application_admin () + AND (owned_by_user_id IS NULL) + ) + ) + AND (source_type <> 'default_document'::TEXT) + ) +); + +CREATE POLICY "Allow authenticated users to CRUD their own rows" ON "public"."favorite_documents" AS permissive FOR ALL TO authenticated USING ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) +) +WITH + CHECK ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = user_id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Allow authenticated users to access own profile" ON "public"."profiles" AS permissive FOR +SELECT + TO authenticated USING ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Users can insert their own profile." ON "public"."profiles" AS permissive FOR insert TO public +WITH + CHECK ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Users can update own profile." ON "public"."profiles" AS permissive +FOR UPDATE + TO public USING ( + ( + ( + ( + SELECT + auth.uid () AS uid + ) = id + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Users can insert their own hidden default docs" ON "public"."user_hidden_default_documents" AS permissive FOR insert TO authenticated +WITH + CHECK ( + ( + ( + user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +CREATE POLICY "Users can view their own hidden default docs" ON "public"."user_hidden_default_documents" AS permissive FOR +SELECT + TO authenticated USING ( + ( + ( + user_id = ( + SELECT + auth.uid () AS uid + ) + ) + AND (NOT public.is_current_user_banned_or_deleted ()) + ) + ); + +-- manually written to cover schemas not covered by `supabase db diff --schema public` (e.g. storage) +CREATE POLICY "Authenticated users can upload a new document." ON storage.objects AS PERMISSIVE FOR INSERT TO authenticated +WITH + CHECK ( + bucket_id = 'documents' + AND (storage.foldername (name)) [1] = ( + SELECT + auth.uid () + )::TEXT + AND NOT public.is_current_user_banned_or_deleted () + ); + +CREATE POLICY "Users can only select their own documents." ON storage.objects AS PERMISSIVE FOR +SELECT + TO authenticated USING ( + bucket_id = 'documents' + AND owner_id = ( + SELECT + auth.uid () + )::TEXT + AND (storage.foldername (name)) [1] = ( + SELECT + auth.uid () + )::TEXT + AND NOT public.is_current_user_banned_or_deleted () + ); + +CREATE POLICY "Users can update their own document." ON storage.objects AS PERMISSIVE +FOR UPDATE + TO authenticated USING ( + bucket_id = 'documents' + AND owner_id = ( + SELECT + auth.uid () + )::TEXT + AND (storage.foldername (name)) [1] = ( + SELECT + auth.uid () + )::TEXT + AND NOT public.is_current_user_banned_or_deleted () + ) +WITH + CHECK ( + bucket_id = 'documents' + AND owner_id = ( + SELECT + auth.uid () + )::TEXT + AND (storage.foldername (name)) [1] = ( + SELECT + auth.uid () + )::TEXT + AND NOT public.is_current_user_banned_or_deleted () + ); + +CREATE POLICY "Users can delete objects where their user ID is in the path" ON storage.objects AS PERMISSIVE FOR DELETE TO authenticated USING ( + bucket_id = 'documents' + AND owner_id = ( + SELECT + auth.uid () + )::TEXT + AND (storage.foldername (name)) [1] = ( + SELECT + auth.uid () + )::TEXT + AND NOT public.is_current_user_banned_or_deleted () +); diff --git a/apps/backend/supabase/schemas/schema.sql b/apps/backend/supabase/schemas/schema.sql index 78e2df653..f4bb68720 100644 --- a/apps/backend/supabase/schemas/schema.sql +++ b/apps/backend/supabase/schemas/schema.sql @@ -234,8 +234,8 @@ SET SET "statement_timeout" TO '60000' AS $$ BEGIN - IF public.is_current_user_banned() THEN - RAISE EXCEPTION 'Permission denied: banned users may not delete their account'; + IF public.is_current_user_banned_or_deleted() THEN + RAISE EXCEPTION 'Permission denied: banned or deleted users may not delete their account'; END IF; DELETE FROM auth.users WHERE id = auth.uid(); @@ -813,26 +813,31 @@ SET "search_path" TO '' AS $$ SELECT EXISTS (SELECT 1 FROM public.application_admins WHERE user_id = auth.uid()) - AND NOT (SELECT public.is_current_user_banned()); + AND NOT (SELECT public.is_current_user_banned_or_deleted()); $$; ALTER FUNCTION "public"."is_application_admin" () OWNER TO "postgres"; -CREATE OR REPLACE FUNCTION "public"."is_current_user_banned" () RETURNS BOOLEAN LANGUAGE "sql" SECURITY DEFINER +CREATE OR REPLACE FUNCTION "public"."is_current_user_banned_or_deleted" () RETURNS BOOLEAN LANGUAGE "sql" SECURITY DEFINER SET "search_path" TO '' AS $$ -SELECT EXISTS ( - SELECT 1 - FROM auth.users u - WHERE u.id = auth.uid() - AND u.banned_until IS NOT NULL - AND u.banned_until > now() -); +SELECT + -- Treat a deleted user (valid pre-deletion session, but no row) as banned + NOT EXISTS ( + SELECT 1 FROM auth.users u WHERE u.id = auth.uid() + ) + OR EXISTS ( + SELECT 1 + FROM auth.users u + WHERE u.id = auth.uid() + AND u.banned_until IS NOT NULL + AND u.banned_until > now() + ); $$; -ALTER FUNCTION "public"."is_current_user_banned" () OWNER TO "postgres"; +ALTER FUNCTION "public"."is_current_user_banned_or_deleted" () OWNER TO "postgres"; -COMMENT ON FUNCTION "public"."is_current_user_banned" () IS 'Returns TRUE if the current user is banned (auth.users.banned_until in the future).'; +COMMENT ON FUNCTION "public"."is_current_user_banned_or_deleted" () IS 'Returns TRUE if the current user is banned (auth.users.banned_until in the future) or deleted (session issued pre-deletion).'; CREATE OR REPLACE FUNCTION "public"."maintain_chat_messages_document_references" () RETURNS "trigger" LANGUAGE "plpgsql" SET @@ -1808,7 +1813,7 @@ CREATE POLICY "Allow authenticated users to CRUD their own chat_messages" ON "pu ) ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1832,7 +1837,7 @@ WITH ) ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -1844,7 +1849,7 @@ CREATE POLICY "Allow authenticated users to CRUD their own chats" ON "public"."c "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1856,7 +1861,7 @@ WITH "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -1868,7 +1873,7 @@ CREATE POLICY "Allow authenticated users to CRUD their own document_folders" ON "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1880,7 +1885,7 @@ WITH "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -1892,7 +1897,7 @@ CREATE POLICY "Allow authenticated users to CRUD their own rows" ON "public"."fa "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1904,7 +1909,7 @@ WITH "auth"."uid" () AS "uid" ) = "user_id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -1919,7 +1924,7 @@ CREATE POLICY "Allow authenticated users to access own or public document_chun" ) ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1932,7 +1937,7 @@ WITH "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) OR ( "public"."is_application_admin" () @@ -1952,7 +1957,7 @@ CREATE POLICY "Allow authenticated users to access own or public document_summ" ) ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) WITH @@ -1965,7 +1970,7 @@ WITH "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) OR ( "public"."is_application_admin" () @@ -1984,7 +1989,7 @@ SELECT "auth"."uid" () AS "uid" ) = "id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -1999,7 +2004,7 @@ WITH "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) OR ( "public"."is_application_admin" () @@ -2021,7 +2026,7 @@ SELECT ) ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2035,7 +2040,7 @@ FOR UPDATE "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2049,7 +2054,7 @@ CREATE POLICY "Allow owners to delete documents and admins to delete base know" "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) OR ( "public"."is_application_admin" () @@ -2070,7 +2075,7 @@ WITH "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2084,7 +2089,7 @@ WITH "auth"."uid" () AS "uid" ) = "id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2098,7 +2103,7 @@ FOR UPDATE "auth"."uid" () AS "uid" ) = "id" ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2112,7 +2117,7 @@ SELECT "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ); @@ -2136,7 +2141,7 @@ SELECT "auth"."uid" () AS "uid" ) ) - AND (NOT "public"."is_current_user_banned" ()) + AND (NOT "public"."is_current_user_banned_or_deleted" ()) ) ) ); @@ -2370,15 +2375,15 @@ GRANT ALL ON FUNCTION "public"."is_application_admin" () TO "authenticated"; GRANT ALL ON FUNCTION "public"."is_application_admin" () TO "service_role"; -REVOKE ALL ON FUNCTION "public"."is_current_user_banned" () +REVOKE ALL ON FUNCTION "public"."is_current_user_banned_or_deleted" () FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."is_current_user_banned" () TO "anon"; +GRANT ALL ON FUNCTION "public"."is_current_user_banned_or_deleted" () TO "anon"; -GRANT ALL ON FUNCTION "public"."is_current_user_banned" () TO "authenticated"; +GRANT ALL ON FUNCTION "public"."is_current_user_banned_or_deleted" () TO "authenticated"; -GRANT ALL ON FUNCTION "public"."is_current_user_banned" () TO "service_role"; +GRANT ALL ON FUNCTION "public"."is_current_user_banned_or_deleted" () TO "service_role"; GRANT ALL ON FUNCTION "public"."maintain_chat_messages_document_references" () TO "anon"; diff --git a/apps/frontend/src/api/auth/get-is-user-banned-or-deleted.ts b/apps/frontend/src/api/auth/get-is-user-banned-or-deleted.ts new file mode 100644 index 000000000..d169b50cd --- /dev/null +++ b/apps/frontend/src/api/auth/get-is-user-banned-or-deleted.ts @@ -0,0 +1,13 @@ +import { supabase } from "../../../supabase-client.ts"; + +export async function getIsUserBannedOrDeleted() { + const { data, error } = await supabase.rpc( + "is_current_user_banned_or_deleted", + ); + + if (error) { + throw error; + } + + return data; +} diff --git a/apps/frontend/src/api/auth/get-is-user-banned.ts b/apps/frontend/src/api/auth/get-is-user-banned.ts deleted file mode 100644 index 948659be7..000000000 --- a/apps/frontend/src/api/auth/get-is-user-banned.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { supabase } from "../../../supabase-client.ts"; - -export async function getIsUserBanned() { - const { data, error } = await supabase.rpc("is_current_user_banned"); - - if (error) { - throw error; - } - - return data; -} diff --git a/apps/frontend/src/api/session/handle-session-change.ts b/apps/frontend/src/api/session/handle-session-change.ts index 1cecde625..03e4baec8 100644 --- a/apps/frontend/src/api/session/handle-session-change.ts +++ b/apps/frontend/src/api/session/handle-session-change.ts @@ -24,7 +24,7 @@ export async function handleSessionChange(session: Session | null) { try { const promises = [ - useAuthStore.getState().checkIsUserBanned(), + useAuthStore.getState().checkIsUserBannedOrDeleted(), useUserFolderStore.getState().getUserFolders(signal), usePublicDocumentsStore.getState().getPublicDocuments(signal), useUserDocumentStore.getState().getUserDocuments(signal), diff --git a/apps/frontend/src/hooks/use-session-redirect.tsx b/apps/frontend/src/hooks/use-session-redirect.tsx index 8bb3f200e..556572641 100644 --- a/apps/frontend/src/hooks/use-session-redirect.tsx +++ b/apps/frontend/src/hooks/use-session-redirect.tsx @@ -7,7 +7,7 @@ import { useAuthErrorStore } from "../store/auth-error-store.ts"; export function useSessionRedirect() { const session = useAuthStore((state) => state.session); - const isBanned = useAuthStore((state) => state.isBanned); + const isBannedOrDeleted = useAuthStore((state) => state.isBannedOrDeleted); const location = useLocation(); const navigate = useNavigate(); @@ -30,21 +30,21 @@ export function useSessionRedirect() { session, pathname: location.pathname, navigate, - isBanned, + isBannedOrDeleted, }).catch(useErrorStore.getState().handleError); - }, [session, location, isBanned, navigate]); + }, [session, location, isBannedOrDeleted, navigate]); } async function redirectBasedOnSession({ session, pathname, navigate, - isBanned, + isBannedOrDeleted, }: { session: Session | null | undefined; pathname: string; navigate: (path: string) => void; - isBanned: boolean | null; + isBannedOrDeleted: boolean | null; }) { /** * On first load the session and user are undefined, and @@ -64,16 +64,16 @@ async function redirectBasedOnSession({ } /** - * If isBanned is null, we don't know yet if the user has been banned or not + * If isBannedOrDeleted is null, we don't know yet if the user has been banned or not */ - if (isBanned === null) { + if (isBannedOrDeleted === null) { return; } /** - * If the user is banned, we log them out + * If the user is banned or has been deleted, we log them out */ - if (isBanned) { + if (isBannedOrDeleted) { await useAuthStore.getState().logout(); useAuthErrorStore .getState() diff --git a/apps/frontend/src/store/auth-store.ts b/apps/frontend/src/store/auth-store.ts index a3ff03479..8ddfb6cbc 100644 --- a/apps/frontend/src/store/auth-store.ts +++ b/apps/frontend/src/store/auth-store.ts @@ -11,7 +11,7 @@ import { captureError } from "../monitoring/capture-error.ts"; import { registerOrRecoverUser } from "../api/auth/register-user.ts"; import { resendOtpEmail } from "../api/auth/resend-otp-email.ts"; import type { Span } from "@sentry/react"; -import { getIsUserBanned } from "../api/auth/get-is-user-banned.ts"; +import { getIsUserBannedOrDeleted } from "../api/auth/get-is-user-banned-or-deleted.ts"; let resendTime: number | null = null; @@ -28,7 +28,15 @@ interface AuthStore { isPasswordRecoveryMode: boolean; isUserAdmin: boolean; isAdminStatusLoaded: boolean; - isBanned: boolean | null; + /** + * You might wonder why we track isBannedOrDeleted + * in the AuthStore. JWT Sessions can't be revoked + * after issuance and are only invalidated via expiration + * Therefore there can be a situation where a user was + * banned / deleted but still has a valid session. + * In this edge-case, we'll need to log them out. + */ + isBannedOrDeleted: boolean | null; register: (args: { firstName: string; lastName: string; @@ -53,7 +61,7 @@ interface AuthStore { }) => Promise; logout: () => Promise; checkIsUserAdmin: (signal: AbortSignal) => Promise; - checkIsUserBanned: () => Promise; + checkIsUserBannedOrDeleted: () => Promise; } export const useAuthStore = create()((set, get) => { @@ -154,7 +162,8 @@ export const useAuthStore = create()((set, get) => { isPasswordRecoveryMode: false, isUserAdmin: false, isAdminStatusLoaded: false, - isBanned: null, + isBannedOrDeleted: null, + isDeleted: null, async register({ firstName, lastName, email, password, span }) { try { @@ -312,7 +321,7 @@ export const useAuthStore = create()((set, get) => { set({ session: null, unconfirmedEmail: null, - isBanned: null, + isBannedOrDeleted: null, emailConfirmationStatus: "unknown", isInitialized: true, isUserAdmin: false, @@ -349,10 +358,10 @@ export const useAuthStore = create()((set, get) => { set({ isUserAdmin: isAdmin, isAdminStatusLoaded }); }, - async checkIsUserBanned() { - const isUserBanned = await getIsUserBanned(); + async checkIsUserBannedOrDeleted() { + const isUserBanned = await getIsUserBannedOrDeleted(); - set({ isBanned: isUserBanned }); + set({ isBannedOrDeleted: isUserBanned }); }, }; }); diff --git a/apps/frontend/tests/e2e/auth.spec.ts b/apps/frontend/tests/e2e/auth.spec.ts index 1bbbf2efe..afc628dc5 100644 --- a/apps/frontend/tests/e2e/auth.spec.ts +++ b/apps/frontend/tests/e2e/auth.spec.ts @@ -598,6 +598,39 @@ testWithRegisteredUser.describe("User ban", async () => { ); }); +testWithLoggedInUser( + "Logged-In User should be logged out when their account is deleted", + async ({ page, account }) => { + // Step 1: Go to the app (user is already logged in via fixture) + await page.goto("/"); + + // Verify user is logged in + await expect( + page.getByRole("heading", { + name: `Willkommen bei BärGPT, ${defaultUserFirstName} ${defaultUserLastName}`, + }), + ).toBeVisible(); + + // Step 2: Delete the account in the database, without ever signing the + // browser session out — simulates a still-valid access token for an + // account that has since been deleted (e.g. deleted from another + // session, or by an admin). + const { error: deleteError } = + await supabaseAdminClient.auth.admin.deleteUser(account.id); + expect(deleteError).toBeNull(); + + // Step 3: Reload the page to trigger the deletion check + await page.reload(); + + // Step 4: Verify user is redirected to the landing page (logged out) + await expect( + page.getByRole("heading", { + name: "BärGPT, der KI-Assistent für die Berliner Verwaltung", + }), + ).toBeVisible(); + }, +); + testWithLoggedInUser( "should allow user to change email address", async ({ page, account }) => { diff --git a/libs/db-schema/index.ts b/libs/db-schema/index.ts index 3af65ff89..d85040b8a 100644 --- a/libs/db-schema/index.ts +++ b/libs/db-schema/index.ts @@ -698,7 +698,7 @@ export type Database = { }[]; }; is_application_admin: { Args: never; Returns: boolean }; - is_current_user_banned: { Args: never; Returns: boolean }; + is_current_user_banned_or_deleted: { Args: never; Returns: boolean }; match_jina_document_chunks: { Args: { allowed_document_ids: number[];