mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/guest action gating (#6643)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user