Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,710 @@
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
} from "react";
import { supabase } from "@app/auth/supabase";
import type {
Session,
User as SupabaseUser,
AuthError,
} from "@supabase/supabase-js";
import {
CreditSummary,
SubscriptionInfo,
CreditCheckResult,
ApiCredits,
} from "@app/types/credits";
import apiClient, {
setGlobalCreditUpdateCallback,
} from "@app/services/apiClient";
import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
getProfilePictureMetadata,
type ProfilePictureMetadata,
} from "@app/services/avatarSyncService";
// Extend Supabase User to include optional username for compatibility
export type User = SupabaseUser & { username?: string };
export interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
status: string;
}
interface AuthContextType {
session: Session | null;
user: User | null;
loading: boolean;
error: AuthError | null;
creditBalance: number | null;
subscription: SubscriptionInfo | null;
creditSummary: CreditSummary | null;
isPro: boolean | null;
trialStatus: TrialStatus | null;
profilePictureUrl: string | null;
profilePictureMetadata: ProfilePictureMetadata | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
updateCredits: (newBalance: number) => void;
refreshCredits: () => Promise<void>;
refreshProStatus: () => Promise<void>;
refreshTrialStatus: () => Promise<void>;
refreshProfilePicture: () => Promise<void>;
refreshProfilePictureMetadata: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
creditBalance: null,
subscription: null,
creditSummary: null,
isPro: null,
trialStatus: null,
profilePictureUrl: null,
profilePictureMetadata: null,
signOut: async () => {},
refreshSession: async () => {},
hasSufficientCredits: () => ({
hasSufficientCredits: false,
currentBalance: 0,
requiredCredits: 0,
}),
updateCredits: () => {},
refreshCredits: async () => {},
refreshProStatus: async () => {},
refreshTrialStatus: async () => {},
refreshProfilePicture: async () => {},
refreshProfilePictureMetadata: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
const [creditBalance, setCreditBalance] = useState<number | null>(null);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
);
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(
null,
);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null);
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
null,
);
const [profilePictureMetadata, setProfilePictureMetadata] =
useState<ProfilePictureMetadata | null>(null);
const fetchCredits = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping credit fetch");
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching credits for user:",
currentSession.user.id,
);
const response = await apiClient.get<ApiCredits>("/api/v1/credits");
const apiCredits = response.data;
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits:
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed:
apiCredits.weeklyCreditsAllocated -
apiCredits.weeklyCreditsRemaining +
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
};
setCreditSummary(credits);
setCreditBalance(credits.creditsRemaining);
const subscriptionInfo: SubscriptionInfo = {
status: "active",
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits,
};
setSubscription(subscriptionInfo);
console.debug("[Auth Debug] Credits fetched successfully:", credits);
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch credits:", error);
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
}
},
[session],
);
const refreshCredits = useCallback(async () => {
await fetchCredits();
}, [fetchCredits]);
const fetchProStatus = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug(
"[Auth Debug] No user session, skipping pro status fetch",
);
setIsPro(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching pro status for user:",
currentSession.user.id,
);
const { data: proStatus, error } = await supabase.rpc("is_pro");
if (error) {
console.error("[Auth Debug] Error checking Pro status:", error);
setIsPro(false); // Default to false if there's an error
} else {
const isProUser = Boolean(proStatus);
setIsPro(isProUser);
console.debug("[Auth Debug] Pro status fetched:", isProUser);
}
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch pro status:", error);
setIsPro(false); // Default to false if there's an error
}
},
[session],
);
const refreshProStatus = useCallback(async () => {
await fetchProStatus();
}, [fetchProStatus]);
const fetchTrialStatus = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug(
"[Auth Debug] No user session, skipping trial status fetch",
);
setTrialStatus(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching trial status for user:",
currentSession.user.id,
);
const { data, error } = await supabase
.from("billing_subscriptions")
.select(
"status, trial_end, has_payment_method, scheduled_subscription_id",
)
.in("status", ["trialing", "incomplete_expired", "canceled"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
console.error("[Auth Debug] Error fetching trial status:", error);
setTrialStatus(null);
return;
}
if (data?.trial_end) {
const trialEnd = new Date(data.trial_end);
const now = new Date();
const daysRemaining = Math.ceil(
(trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
setTrialStatus({
isTrialing: data.status === "trialing" && daysRemaining > 0,
trialEnd: data.trial_end,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method || false,
hasScheduledSub: !!data.scheduled_subscription_id,
status: data.status,
});
console.debug("[Auth Debug] Trial status fetched:", {
status: data.status,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method,
isTrialing: data.status === "trialing" && daysRemaining > 0,
});
} else {
setTrialStatus(null);
}
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch trial status:", error);
setTrialStatus(null);
}
},
[session],
);
const refreshTrialStatus = useCallback(async () => {
await fetchTrialStatus();
}, [fetchTrialStatus]);
const fetchProfilePicture = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug(
"[Auth Debug] No user session, skipping profile picture fetch",
);
setProfilePictureUrl(null);
return;
}
try {
const PROFILE_BUCKET = "profile-pictures";
const profilePath = `${currentSession.user.id}/avatar`;
console.debug(
"[Auth Debug] Fetching profile picture for user:",
currentSession.user.id,
);
const { data, error } = await supabase.storage
.from(PROFILE_BUCKET)
.createSignedUrl(profilePath, 60 * 60);
if (error) {
// Profile picture not found is expected for users without uploads
console.debug(
"[Auth Debug] Profile picture not available:",
error.message,
);
setProfilePictureUrl(null);
} else {
setProfilePictureUrl(data.signedUrl);
console.debug(
"[Auth Debug] Profile picture URL fetched successfully",
);
}
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
setProfilePictureUrl(null);
}
},
[session],
);
const refreshProfilePicture = useCallback(async () => {
await fetchProfilePicture();
}, [fetchProfilePicture]);
const fetchProfilePictureMetadata = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug(
"[Auth Debug] No user session, skipping profile picture metadata fetch",
);
setProfilePictureMetadata(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching profile picture metadata for user:",
currentSession.user.id,
);
const metadata = await getProfilePictureMetadata(
currentSession.user.id,
);
setProfilePictureMetadata(metadata);
console.debug(
"[Auth Debug] Profile picture metadata fetched:",
metadata,
);
} catch (error: unknown) {
console.debug(
"[Auth Debug] Failed to fetch profile picture metadata:",
error,
);
setProfilePictureMetadata(null);
}
},
[session],
);
const refreshProfilePictureMetadata = useCallback(async () => {
await fetchProfilePictureMetadata();
}, [fetchProfilePictureMetadata]);
const updateCredits = useCallback(
(newBalance: number) => {
console.debug("[Auth Debug] Updating credit balance:", {
from: creditBalance,
to: newBalance,
});
setCreditBalance(newBalance);
// Also update the creditSummary if it exists
if (creditSummary) {
const updatedSummary: CreditSummary = {
...creditSummary,
creditsRemaining: newBalance,
currentCredits: newBalance,
};
setCreditSummary(updatedSummary);
}
},
[creditSummary],
);
const hasSufficientCredits = useCallback(
(requiredCredits: number): CreditCheckResult => {
const currentBalance = creditBalance ?? 0;
const hasSufficient = currentBalance >= requiredCredits;
console.debug("[Auth Debug] Credit check:", {
requiredCredits,
currentBalance,
hasSufficient,
});
return {
hasSufficientCredits: hasSufficient,
currentBalance,
requiredCredits,
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance,
};
},
[creditBalance],
);
const refreshSession = async () => {
try {
setLoading(true);
setError(null);
const { data, error } = await supabase.auth.refreshSession();
if (error) {
console.error("[Auth Debug] Session refresh error:", error);
setError(error);
setSession(null);
} else {
console.debug("[Auth Debug] Session refreshed successfully");
setSession(data.session);
}
} catch (err) {
console.error(
"[Auth Debug] Unexpected error during session refresh:",
err,
);
setError(err as AuthError);
} finally {
setLoading(false);
}
};
const signOut = async () => {
try {
setError(null);
const { error } = await supabase.auth.signOut();
if (error) {
console.error("[Auth Debug] Sign out error:", error);
setError(error);
} else {
console.debug("[Auth Debug] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error("[Auth Debug] Unexpected error during sign out:", err);
setError(err as AuthError);
}
};
// Set up global credit update callback
useEffect(() => {
setGlobalCreditUpdateCallback(updateCredits);
}, [updateCredits]);
useEffect(() => {
let mounted = true;
// Load current session on first mount
const initializeAuth = async () => {
try {
console.debug("[Auth Debug] Initializing auth...");
const { data, error } = await supabase.auth.getSession();
if (!mounted) return;
if (error) {
console.error("[Auth Debug] Initial session error:", error);
setError(error);
} else {
console.debug("[Auth Debug] Initial session loaded:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
});
setSession(data.session);
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
if (data.session?.user) {
// Sync OAuth avatar in background
syncOAuthAvatar(data.session.user).catch((err) => {
console.debug(
"[Auth Debug] Failed to sync OAuth avatar on init:",
err,
);
});
await fetchCredits(data.session);
await fetchProStatus(data.session);
await fetchTrialStatus(data.session);
await fetchProfilePictureMetadata(data.session);
// Small delay to allow avatar sync to complete if quick
setTimeout(() => {
fetchProfilePicture(data.session);
}, 500);
}
}
} catch (err) {
console.error(
"[Auth Debug] Unexpected error during auth initialization:",
err,
);
if (mounted) {
setError(err as AuthError);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
initializeAuth();
// Subscribe to auth state changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, newSession) => {
if (!mounted) return;
console.debug("[Auth Debug] Auth state change:", {
event,
hasSession: !!newSession,
userId: newSession?.user?.id,
email: newSession?.user?.email,
timestamp: new Date().toISOString(),
});
// Don't run supabase calls inside this callback; schedule them
setTimeout(() => {
if (mounted) {
setSession(newSession);
setError(null);
// Additional handling for specific events
if (event === "SIGNED_OUT") {
console.debug("[Auth Debug] User signed out, clearing session");
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
setIsPro(null);
setTrialStatus(null);
setProfilePictureUrl(null);
setProfilePictureMetadata(null);
} else if (event === "SIGNED_IN") {
console.debug("[Auth Debug] User signed in successfully");
if (newSession?.user) {
setLoading(true);
// Sync OAuth avatar in background (don't block other fetches)
syncOAuthAvatar(newSession.user).catch((err) => {
console.debug("[Auth Debug] Failed to sync OAuth avatar:", err);
});
// Fetch user data in parallel
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
]).then(() => {
// Fetch profile picture AFTER sync has had time to complete
// Use a small delay to allow avatar sync to finish if it's quick
setTimeout(() => {
fetchProfilePicture(newSession).finally(() => {
setLoading(false);
console.debug(
"[Auth Debug] User data fully loaded after sign in",
);
});
}, 500);
});
}
} else if (event === "TOKEN_REFRESHED") {
console.debug("[Auth Debug] Token refreshed");
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
if (newSession?.user) {
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession),
]).then(() => {
console.debug(
"[Auth Debug] User data refreshed after token refresh",
);
});
}
} else if (event === "USER_UPDATED") {
console.debug("[Auth Debug] User updated");
// Check if this is a pending OAuth upgrade completion
const pendingUpgrade = sessionStorage.getItem("pendingUpgrade");
const upgradeProvider = sessionStorage.getItem("upgradeProvider");
if (
pendingUpgrade &&
newSession?.user &&
newSession.user.is_anonymous === false
) {
console.debug(
"[Auth Debug] Processing pending OAuth upgrade:",
upgradeProvider,
);
// Clear the flags first to prevent loops
sessionStorage.removeItem("pendingUpgrade");
sessionStorage.removeItem("upgradeProvider");
// Synchronize with backend
synchronizeUserUpgrade(upgradeProvider || undefined)
.then(() => {
console.debug(
"[Auth Debug] User upgrade synchronized successfully",
);
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
if (newSession?.user) {
return Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession),
]);
}
})
.then(() => {
console.debug(
"[Auth Debug] User data refreshed after upgrade",
);
})
.catch((err) => {
console.error(
"[Auth Debug] Failed to synchronize user upgrade:",
err,
);
});
}
}
}
}, 0);
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
creditBalance,
subscription,
creditSummary,
isPro,
trialStatus,
profilePictureUrl,
profilePictureMetadata,
signOut,
refreshSession,
hasSufficientCredits,
updateCredits,
refreshCredits,
refreshProStatus,
refreshTrialStatus,
refreshProfilePicture,
refreshProfilePictureMetadata,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Debug hook to expose auth state for debugging
export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug("[Auth Debug] Current auth state:", {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
provider: auth.user?.app_metadata?.provider,
});
}, [auth.session, auth.user, auth.loading, auth.error]);
return auth;
}