Feature/v2/guest action gating (#6643)

This commit is contained in:
Anthony Stirling
2026-06-12 13:13:38 +01:00
committed by GitHub
parent f5e697347b
commit b11c272e87
12 changed files with 291 additions and 93 deletions
@@ -14,6 +14,14 @@ export interface AuthContextType {
* should treat the resulting string as opaque display text.
*/
displayName: string | null;
/**
* Whether the current session is an anonymous / guest one. Each layer
* derives this from its own native user shape (Supabase `is_anonymous` in
* SaaS, the Spring anonymous flag in proprietary). Always `false` in core
* OSS, which has no auth context. Consumers use it to gate account-only
* actions (cloud folders, MCP) without reaching into a layer-specific user.
*/
isAnonymous: boolean;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
@@ -29,6 +37,7 @@ export function useAuth(): AuthContextType {
session: null,
user: null,
displayName: null,
isAnonymous: false,
loading: false,
error: null,
signOut: async () => {},
@@ -35,6 +35,7 @@ import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import RefreshIcon from "@mui/icons-material/Refresh";
import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { useFolders } from "@app/contexts/FolderContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
@@ -107,17 +108,27 @@ export default function FileManagerView() {
const isMobile = useIsMobile();
const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile;
// Guests (anonymous sessions) have no server-side storage, so every cloud
// action is account-only. Rather than let the click fire a guaranteed 401
// (which surfaced as an error toast), we disable the control and explain why
// on hover - the same affordance the storage-disabled / wrong-tab gates use.
const { isAnonymous } = useAuth();
const signInRequiredReason = isAnonymous
? t("filesPage.signInRequired", "Sign in to use cloud storage.")
: null;
// Server storage gate; mirrors ConfigController's storageEnabled
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
// visible but disabled with an explanatory tooltip (discoverability beats
// hiding - mirrors the New folder / Manage sharing gates in this view).
const uploadEnabled = appConfig?.storageEnabled === true;
const saveToServerDisabledReason: string | null = uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
);
const saveToServerDisabledReason: string | null =
signInRequiredReason ??
(uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
));
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard();
@@ -805,6 +816,11 @@ export default function FileManagerView() {
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Guests can't use cloud folders at all - say so before any tab/storage
// hint, since switching tabs wouldn't help them.
if (signInRequiredReason) {
return signInRequiredReason;
}
if (currentTab === "local") {
return t(
"filesPage.localFoldersUnavailable",
@@ -828,7 +844,7 @@ export default function FileManagerView() {
);
}
return null;
}, [currentTab, folders.serverReachable, t]);
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
return (
<div className="files-page" ref={dropZoneRef}>
@@ -895,14 +911,17 @@ export default function FileManagerView() {
/>
<div className="files-page-header-actions">
<Tooltip
label={t("filesPage.refresh", "Refresh from server")}
label={
signInRequiredReason ??
t("filesPage.refresh", "Refresh from server")
}
withinPortal
>
<ActionIcon
variant="default"
size="md"
loading={refreshing}
disabled={refreshing}
disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing}
onClick={handleRefresh}
aria-label={t("filesPage.refresh", "Refresh from server")}
@@ -25,6 +25,7 @@ import {
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
@@ -140,6 +141,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const folders = useFolders();
const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig();
const { isAnonymous } = useAuth();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
const [loading, setLoading] = useState(true);
@@ -166,6 +168,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const merged = await reconcileServerFiles(localLeaf, {
storageEnabled,
shareLinksEnabled,
isAnonymous,
});
// Drop the merged result if a newer refresh has already started -
// otherwise its stale snapshot will clobber the newer one's state.
@@ -181,7 +184,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
// Only the latest refresh should clear the loading state.
if (gen === refreshGenRef.current) setLoading(false);
}
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
}, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]);
useEffect(() => {
void refresh();
@@ -39,6 +39,31 @@ vi.mock("@app/services/folderSyncService", () => ({
},
}));
// FolderProvider only pulls from the server for a confirmed, non-anonymous
// user (guests have no cloud storage). Mock useAuth as a signed-in user so the
// pull runs; the guest-skip path is covered by its own test below.
const { mockAuth } = vi.hoisted(() => ({
mockAuth: {
user: { id: "test-user", is_anonymous: false } as Record<
string,
unknown
> | null,
isAnonymous: false,
},
}));
vi.mock("@app/auth/UseSession", () => ({
useAuth: () => ({
user: mockAuth.user,
isAnonymous: mockAuth.isAnonymous,
session: null,
displayName: null,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
}),
}));
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
// after every state change, so a stateless [] mock would clobber pull results.
const { mockIdb } = vi.hoisted(() => ({
@@ -133,6 +158,9 @@ describe("FolderContext sync-banner gating", () => {
mockList.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
// Default each test to a signed-in, non-anonymous user so the pull runs.
mockAuth.user = { id: "test-user", is_anonymous: false };
mockAuth.isAnonymous = false;
});
test("401 (unauthorized) does NOT surface a banner", async () => {
@@ -184,6 +212,28 @@ describe("FolderContext sync-banner gating", () => {
expect(screen.getByTestId("error").textContent).toBe("<null>");
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("guest (anonymous) session does NOT pull from the server", async () => {
// Guests have no cloud storage; pulling would 401 and (historically)
// surface a toast. The provider must make no folder request at all.
mockAuth.user = { id: "guest", is_anonymous: true };
mockAuth.isAnonymous = true;
mockList.mockResolvedValue([]);
render(
<MemoryRouter>
<FolderProvider>
<Probe />
</FolderProvider>
</MemoryRouter>,
);
// Let mount effects run; the pull must never fire.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockList).not.toHaveBeenCalled();
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
});
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
@@ -194,6 +244,9 @@ describe("FolderContext stale-folder 404 cleanup", () => {
mockDelete.mockReset();
// Reset the stateful IDB mock so each test starts with an empty cache.
mockIdb.folders = [];
// Signed-in, non-anonymous user so pullFromServer runs.
mockAuth.user = { id: "test-user", is_anonymous: false };
mockAuth.isAnonymous = false;
});
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
@@ -38,6 +38,7 @@ import {
} from "@app/types/folder";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes";
@@ -369,10 +370,24 @@ export function FolderProvider({ children }: FolderProviderProps) {
// /login has no session yet, so the pull would be a guaranteed 401.
const location = useLocation();
const onAuthRoute = isAuthRoute(location.pathname);
// Guests (anonymous sessions) have no server-side storage, so a pull is a
// guaranteed 401 - skip it once we know the session is anonymous. This is
// what keeps the "must sign in" affordance toast-free: with no folder request
// fired, there's no error to surface. We gate on `isAnonymous` (which is
// false for a real signed-in user) rather than the auth `loading` flag, which
// can stay true for an authenticated session and would otherwise block the
// pull for legitimate users.
const { user, isAnonymous } = useAuth();
useEffect(() => {
if (!storageBackedByServer || onAuthRoute) return;
// Only pull once we have a confirmed, non-anonymous user. Skipping while
// `user` is still null avoids a stray 401 in the brief window before an
// anonymous session resolves (at which point `isAnonymous` flips true and
// keeps us out). `isAnonymous` alone isn't enough because it defaults false
// before the session loads; the auth `loading` flag is unreliable (it can
// stay true for a valid signed-in session), so we key off the user object.
if (!storageBackedByServer || onAuthRoute || !user || isAnonymous) return;
void pullFromServer();
}, [pullFromServer, storageBackedByServer, onAuthRoute]);
}, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]);
const foldersById = useMemo(() => {
const map = new Map<FolderId, FolderRecord>();
@@ -63,6 +63,12 @@ interface AccessedShareLinkResponse {
export interface ReconcileOptions {
storageEnabled: boolean;
shareLinksEnabled: boolean;
/**
* Guests (anonymous sessions) have no cloud library; pulling it just 401s and
* surfaces a "sign in to load cloud files" toast. Skip the server pull for
* them and fall back to locally-cached files only.
*/
isAnonymous?: boolean;
}
function normalizeServerFileName(fileName: string | undefined | null): string {
@@ -108,7 +114,7 @@ export async function reconcileServerFiles(
localStubs: StirlingFileStub[],
opts: ReconcileOptions,
): Promise<StirlingFileStub[]> {
if (!opts.storageEnabled) {
if (!opts.storageEnabled || opts.isAnonymous) {
return localStubs;
}