From d58d59a3898a206a7d459856b47cdbca94fb122f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:29:39 +0700 Subject: [PATCH 1/2] feat(app): sign-out revokes the session in Crove first Every SPA sign-out control now goes through one helper that revokes the current session via sessions.revoke (3 s timeout, best effort) and then ends the Supabase session locally. The sessions screen keeps its own flow to avoid a double revoke. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/app/src/lib/session-actions.ts | 19 +++++++++ apps/app/src/lib/sign-out.ts | 35 +++++++++++++++++ apps/app/src/router.tsx | 3 +- apps/app/src/routes/invite.tsx | 4 +- apps/app/src/routes/onboarding.tsx | 4 +- apps/app/src/routes/org-layout.tsx | 4 +- apps/app/test/sign-out.test.ts | 60 +++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 apps/app/src/lib/session-actions.ts create mode 100644 apps/app/src/lib/sign-out.ts create mode 100644 apps/app/test/sign-out.test.ts diff --git a/apps/app/src/lib/session-actions.ts b/apps/app/src/lib/session-actions.ts new file mode 100644 index 00000000..01435c49 --- /dev/null +++ b/apps/app/src/lib/session-actions.ts @@ -0,0 +1,19 @@ +// Binds the sign-out helper to the app's API and auth clients. Every "Đăng xuất" control goes through signOut(). +import { client } from './api.ts'; +import { auth } from './auth.ts'; +import { sessionIdFromToken } from './sessions.ts'; +import { signOutWithRevoke } from './sign-out.ts'; + +// Revokes this session in Crove, then signs this device out of Supabase Auth (auth-js tells the other tabs, and +// main.tsx sends every tab to /login). Scope is local: signing out everywhere is the sessions screen's job. +export function signOut(): Promise { + return signOutWithRevoke({ + currentSession: async () => { + const { data } = await auth().getSession(); + const sessionId = sessionIdFromToken(data.session?.access_token); + return data.session && sessionId ? { userId: data.session.user.id, sessionId } : null; + }, + revoke: (input, signal) => client.sessions.revoke(input, { signal }), + signOut: () => auth().signOut({ scope: 'local' }), + }); +} diff --git a/apps/app/src/lib/sign-out.ts b/apps/app/src/lib/sign-out.ts new file mode 100644 index 00000000..f26118af --- /dev/null +++ b/apps/app/src/lib/sign-out.ts @@ -0,0 +1,35 @@ +// One sign-out path for the SPA (issue #150). A Supabase sign-out leaves the access token valid until it expires, so +// the current session is revoked in Crove first (an own revoke is immediate), then the Supabase session is ended. +// The revoke is best effort: it never blocks or prevents the sign-out. No browser or API imports, so it is unit tested. + +export const REVOKE_TIMEOUT_MS = 3000; + +export interface SignOutDeps { + // The signed-in user and Supabase Auth session id, or null when nobody is signed in or the token has no session id. + currentSession: () => Promise<{ userId: string; sessionId: string } | null>; + revoke: (input: { userId: string; sessionId: string }, signal: AbortSignal) => Promise; + signOut: () => Promise; + timeoutMs?: number; +} + +export async function signOutWithRevoke(deps: SignOutDeps): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + try { + const session = await deps.currentSession(); + if (session) { + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(); + }, deps.timeoutMs ?? REVOKE_TIMEOUT_MS); + }); + await Promise.race([deps.revoke(session, controller.signal), timeout]); + } + } catch { + // A failed revoke must not keep the user signed in; the token then lives until it expires, as before. + } finally { + clearTimeout(timer); + } + await deps.signOut(); +} diff --git a/apps/app/src/router.tsx b/apps/app/src/router.tsx index f1eb9cdc..71cfeb79 100644 --- a/apps/app/src/router.tsx +++ b/apps/app/src/router.tsx @@ -2,6 +2,7 @@ import { Outlet, createRootRoute, createRoute, createRouter, redirect, type Erro import { Button } from './components/ui.tsx'; import { getCurrentOrg, isUnauthenticated, orpc, queryClient, setCurrentOrg } from './lib/api.ts'; import { accessToken, auth } from './lib/auth.ts'; +import { signOut } from './lib/session-actions.ts'; import { pendingInvite, rememberInvite, takeTokenFromHash } from './lib/invite.ts'; import { AuthPage } from './routes/auth.tsx'; import { DocumentsPage } from './routes/documents.tsx'; @@ -26,7 +27,7 @@ function ErrorPage({ error }: ErrorComponentProps) {

{error instanceof Error ? error.message : 'Lỗi không xác định'}

-
diff --git a/apps/app/src/routes/invite.tsx b/apps/app/src/routes/invite.tsx index dce3185b..144296ff 100644 --- a/apps/app/src/routes/invite.tsx +++ b/apps/app/src/routes/invite.tsx @@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'; import type { ReactNode } from 'react'; import { Button, Card, ErrorText, Logo, Spinner } from '../components/ui.tsx'; import { errorMessage, orpc, queryClient } from '../lib/api.ts'; -import { auth } from '../lib/auth.ts'; +import { signOut } from '../lib/session-actions.ts'; import { formatDateTime } from '../lib/format.ts'; import { forgetInvite, pendingInvite } from '../lib/invite.ts'; @@ -77,7 +77,7 @@ export function InvitePage() {
{mismatch && ( // The token stays in storage, so after signing in with the invited email the user comes back here. - )} diff --git a/apps/app/src/routes/onboarding.tsx b/apps/app/src/routes/onboarding.tsx index f93718d4..d3a6b8b6 100644 --- a/apps/app/src/routes/onboarding.tsx +++ b/apps/app/src/routes/onboarding.tsx @@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'; import { useRef, useState, type FormEvent } from 'react'; import { Button, Card, ErrorText, Input, Logo } from '../components/ui.tsx'; import { errorMessage, orpc, queryClient } from '../lib/api.ts'; -import { auth } from '../lib/auth.ts'; +import { signOut } from '../lib/session-actions.ts'; export function OnboardingPage() { const navigate = useNavigate(); @@ -44,7 +44,7 @@ export function OnboardingPage() { {create.isPending ? 'Đang tạo…' : 'Tạo tổ chức'} - diff --git a/apps/app/src/routes/org-layout.tsx b/apps/app/src/routes/org-layout.tsx index 66008b41..f541255f 100644 --- a/apps/app/src/routes/org-layout.tsx +++ b/apps/app/src/routes/org-layout.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Link, Outlet, useNavigate, useParams } from '@tanstack/react-router'; import { Logo, cx } from '../components/ui.tsx'; import { orpc } from '../lib/api.ts'; -import { auth } from '../lib/auth.ts'; +import { signOut } from '../lib/session-actions.ts'; const NAV = [ { to: '/o/$slug/inbox', label: 'Hộp thư chứng từ' }, @@ -57,7 +57,7 @@ export function OrgLayout() {

{me.data?.email}

-
diff --git a/apps/app/test/sign-out.test.ts b/apps/app/test/sign-out.test.ts new file mode 100644 index 00000000..a80e7a61 --- /dev/null +++ b/apps/app/test/sign-out.test.ts @@ -0,0 +1,60 @@ +// The sign-out helper (issue #150): the revoke is best effort, the sign-out always happens. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { signOutWithRevoke } from '../src/lib/sign-out.ts'; + +const session = { userId: 'u1', sessionId: 's1' }; + +afterEach(() => vi.useRealTimers()); + +describe('signOutWithRevoke', () => { + it('revokes the current session before signing out', async () => { + const order: string[] = []; + await signOutWithRevoke({ + currentSession: async () => session, + revoke: async (input) => void order.push(`revoke:${input.userId}:${input.sessionId}`), + signOut: async () => void order.push('signOut'), + }); + expect(order).toEqual(['revoke:u1:s1', 'signOut']); + }); + + it('signs out when the revoke fails', async () => { + const signOut = vi.fn(async () => undefined); + await signOutWithRevoke({ currentSession: async () => session, revoke: async () => Promise.reject(new Error('boom')), signOut }); + expect(signOut).toHaveBeenCalledOnce(); + }); + + it('signs out after the timeout when the revoke hangs, and aborts it', async () => { + vi.useFakeTimers(); + let aborted = false; + const signOut = vi.fn(async () => undefined); + const done = signOutWithRevoke({ + currentSession: async () => session, + revoke: (_input, signal) => { + signal.addEventListener('abort', () => (aborted = true)); + return new Promise(() => undefined); + }, + signOut, + timeoutMs: 3000, + }); + await vi.advanceTimersByTimeAsync(2999); + expect(signOut).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(signOut).toHaveBeenCalledOnce(); + expect(aborted).toBe(true); + }); + + it('skips the revoke when there is no session and still signs out', async () => { + const revoke = vi.fn(async () => undefined); + const signOut = vi.fn(async () => undefined); + await signOutWithRevoke({ currentSession: async () => null, revoke, signOut }); + expect(revoke).not.toHaveBeenCalled(); + expect(signOut).toHaveBeenCalledOnce(); + }); + + it('signs out when reading the session throws', async () => { + const signOut = vi.fn(async () => undefined); + await signOutWithRevoke({ currentSession: async () => Promise.reject(new Error('storage')), revoke: async () => undefined, signOut }); + expect(signOut).toHaveBeenCalledOnce(); + }); +}); From 63c30bb704729d41c0a75fc932b7be148c16246f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:40:23 +0700 Subject: [PATCH 2/2] fix(app): the sign-out revoke timeout also covers reading the session Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/app/src/lib/sign-out.ts | 23 +++++++++++++---------- apps/app/test/sign-out.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/app/src/lib/sign-out.ts b/apps/app/src/lib/sign-out.ts index f26118af..c1474815 100644 --- a/apps/app/src/lib/sign-out.ts +++ b/apps/app/src/lib/sign-out.ts @@ -16,16 +16,19 @@ export async function signOutWithRevoke(deps: SignOutDeps): Promise { const controller = new AbortController(); let timer: ReturnType | undefined; try { - const session = await deps.currentSession(); - if (session) { - const timeout = new Promise((resolve) => { - timer = setTimeout(() => { - controller.abort(); - resolve(); - }, deps.timeoutMs ?? REVOKE_TIMEOUT_MS); - }); - await Promise.race([deps.revoke(session, controller.signal), timeout]); - } + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(); + }, deps.timeoutMs ?? REVOKE_TIMEOUT_MS); + }); + // The budget covers reading the session too: auth-js can retry a token refresh for a long time when offline. + const revoke = (async () => { + const session = await deps.currentSession(); + if (session) await deps.revoke(session, controller.signal); + })(); + revoke.catch(() => undefined); + await Promise.race([revoke, timeout]); } catch { // A failed revoke must not keep the user signed in; the token then lives until it expires, as before. } finally { diff --git a/apps/app/test/sign-out.test.ts b/apps/app/test/sign-out.test.ts index a80e7a61..e1dfcb29 100644 --- a/apps/app/test/sign-out.test.ts +++ b/apps/app/test/sign-out.test.ts @@ -57,4 +57,13 @@ describe('signOutWithRevoke', () => { await signOutWithRevoke({ currentSession: async () => Promise.reject(new Error('storage')), revoke: async () => undefined, signOut }); expect(signOut).toHaveBeenCalledOnce(); }); + + it('signs out at the timeout when reading the session hangs', async () => { + vi.useFakeTimers(); + const signOut = vi.fn(async () => undefined); + const done = signOutWithRevoke({ currentSession: () => new Promise(() => undefined), revoke: async () => undefined, signOut, timeoutMs: 3000 }); + await vi.advanceTimersByTimeAsync(3000); + await done; + expect(signOut).toHaveBeenCalledOnce(); + }); });