Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/app/src/lib/session-actions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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' }),
});
}
38 changes: 38 additions & 0 deletions apps/app/src/lib/sign-out.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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<unknown>;
signOut: () => Promise<unknown>;
timeoutMs?: number;
}

export async function signOutWithRevoke(deps: SignOutDeps): Promise<void> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeout = new Promise<void>((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 {
clearTimeout(timer);
}
await deps.signOut();
}
3 changes: 2 additions & 1 deletion apps/app/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,7 +27,7 @@ function ErrorPage({ error }: ErrorComponentProps) {
<p className="mt-2 text-sm text-fg-2">{error instanceof Error ? error.message : 'Lỗi không xác định'}</p>
<div className="mt-6 flex justify-center gap-2">
<Button onClick={() => window.location.reload()}>Tải lại</Button>
<Button variant="ghost" onClick={() => void auth().signOut({ scope: 'local' }).then(() => window.location.assign('/login'))}>
<Button variant="ghost" onClick={() => void signOut().then(() => window.location.assign('/login'))}>
Đăng xuất
</Button>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/routes/invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,7 +77,7 @@ export function InvitePage() {
<div className="mt-6 flex flex-wrap gap-2">
{mismatch && (
// The token stays in storage, so after signing in with the invited email the user comes back here.
<Button variant="primary" onClick={() => void auth().signOut({ scope: 'local' })}>
<Button variant="primary" onClick={() => void signOut()}>
Đăng xuất để dùng email khác
</Button>
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/routes/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -44,7 +44,7 @@ export function OnboardingPage() {
{create.isPending ? 'Đang tạo…' : 'Tạo tổ chức'}
</Button>
</form>
<button className="mt-4 text-sm text-fg-3 hover:text-fg-2" onClick={() => void auth().signOut()}>
<button className="mt-4 text-sm text-fg-3 hover:text-fg-2" onClick={() => void signOut()}>
Đăng xuất
</button>
</Card>
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/routes/org-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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ừ' },
Expand Down Expand Up @@ -57,7 +57,7 @@ export function OrgLayout() {
</nav>
<div className="mt-auto border-t border-line px-2 pt-3 text-xs text-fg-3">
<p className="truncate">{me.data?.email}</p>
<button className="mt-1 hover:text-fg-2" onClick={() => void auth().signOut()}>
<button className="mt-1 hover:text-fg-2" onClick={() => void signOut()}>
Đăng xuất
</button>
</div>
Expand Down
69 changes: 69 additions & 0 deletions apps/app/test/sign-out.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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();
});

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();
});
});
Loading