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,233 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@app/auth/supabase";
import { Button } from "@mantine/core";
import { withBasePath } from "@app/constants/app";
interface CallbackState {
status: "processing" | "success" | "error";
message: string;
details?: Record<string, unknown>;
}
export default function AuthCallback() {
const navigate = useNavigate();
const [state, setState] = useState<CallbackState>({
status: "processing",
message: "Processing authentication...",
});
useEffect(() => {
const handleCallback = async () => {
try {
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");
const next = url.searchParams.get("next") || "/";
console.log("[Auth Callback Debug] URL parameters:", {
hasCode: !!code,
hasError: !!error,
error,
errorDescription,
next,
fullUrl: window.location.href,
});
// Handle OAuth errors
if (error) {
const errorMsg = errorDescription || error;
console.error("[Auth Callback Debug] OAuth error:", {
error,
errorDescription,
});
setState({
status: "error",
message: `Authentication failed: ${errorMsg}`,
details: { error, errorDescription },
});
// Redirect to login page after 3 seconds
setTimeout(() => navigate("/login", { replace: true }), 3000);
return;
}
// If PKCE/SSR-style code is present, exchange it for a session
if (code) {
console.log("[Auth Callback Debug] Exchanging code for session...");
setState({
status: "processing",
message: "Exchanging authorization code...",
});
const { data, error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) {
console.error(
"[Auth Callback Debug] Code exchange error:",
exchangeError,
);
setState({
status: "error",
message: `Failed to complete sign in: ${exchangeError.message}`,
details: { exchangeError },
});
setTimeout(() => navigate("/login", { replace: true }), 3000);
return;
}
console.log("[Auth Callback Debug] Code exchange successful:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
});
setState({
status: "success",
message: "Sign in successful! Redirecting...",
details: {
userId: data.session?.user?.id,
email: data.session?.user?.email,
provider: data.session?.user?.app_metadata?.provider,
},
});
} else {
// No code present - might already be authenticated
console.log(
"[Auth Callback Debug] No code present, checking existing session...",
);
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData.session) {
console.log("[Auth Callback Debug] Existing session found");
setState({
status: "success",
message: "Already signed in! Redirecting...",
});
} else {
console.log("[Auth Callback Debug] No session found");
setState({
status: "error",
message: "No authentication data found",
});
setTimeout(() => navigate("/login", { replace: true }), 2000);
return;
}
}
// Redirect to the intended destination
const destination = next.startsWith("/") ? next : "/";
console.log("[Auth Callback Debug] Redirecting to:", destination);
setTimeout(() => navigate(destination, { replace: true }), 1500);
} catch (err) {
console.error("[Auth Callback Debug] Unexpected error:", err);
setState({
status: "error",
message: `Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
details: { error: err },
});
setTimeout(() => navigate("/login", { replace: true }), 3000);
}
};
handleCallback();
}, [navigate]);
const getStatusColor = () => {
switch (state.status) {
case "processing":
return "text-blue-600";
case "success":
return "text-green-600";
case "error":
return "text-red-600";
default:
return "text-gray-600";
}
};
const getTitle = () => {
switch (state.status) {
case "processing":
return "Signing you in";
case "success":
return "You're all set!";
case "error":
return "Authentication failed";
default:
return "Authentication";
}
};
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
</div>
<div
className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}
>
<div className="text-center">
<img
src={withBasePath("/modern-logo/StirlingPDFLogoNoTextDark.svg")}
alt="Stirling PDF"
className="mx-auto mb-5 h-8 opacity-80"
/>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{getTitle()}
</h1>
<p className={`text-base ${getStatusColor()}`}>{state.message}</p>
{state.status === "processing" && (
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
</div>
</div>
)}
{/* Action button - only show if error */}
<div className="mt-6 flex items-center justify-center gap-3">
{(() => {
if (state.status === "error") {
return (
<Button
onClick={() => navigate("/login", { replace: true })}
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
>
Back to login
</Button>
);
}
})()}
</div>
{import.meta.env.DEV && state.details && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Debug Information
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(state.details, null, 2)}
</pre>
</details>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import React, { useMemo } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
import { isToolRoute } from "@app/utils/pathUtils";
import HomePage from "@app/pages/HomePage";
import Login from "@app/routes/Login";
import GuestUserBanner from "@app/components/auth/GuestUserBanner";
import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner";
export default function Landing() {
const { session, loading } = useAuth();
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } =
useAutoAnonymousAuth();
const location = useLocation();
// Check if current path is a tool (prevents premature navigation on first render)
const isCurrentPathTool = useMemo(
() => isToolRoute(location.pathname),
[location.pathname],
);
// Match the same guarded bypass used in RequireAuth
const isLocalhost =
typeof window !== "undefined" &&
/^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
const devBypassEnabled = Boolean(
import.meta.env.DEV &&
isLocalhost &&
import.meta.env.VITE_DEV_BYPASS_AUTH === "true",
);
console.log("[Landing] State:", {
pathname: location.pathname,
loading,
hasSession: !!session,
isAutoAuthenticating,
shouldTriggerAutoAuth,
isCurrentPathTool,
autoAuthError,
});
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
if (
loading ||
isAutoAuthenticating ||
(!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)
) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
{isAutoAuthenticating ? "Setting up your session..." : "Loading..."}
</div>
</div>
</div>
);
}
// If we have a session or dev bypass is enabled, show the main app
if (session || devBypassEnabled) {
return (
<>
<GuestUserBanner />
<TrialStatusBanner />
<HomePage />
</>
);
}
// If auto-authentication failed, navigate to login with error state
if (autoAuthError && shouldTriggerAutoAuth) {
return (
<Navigate to="/login" replace state={{ autoAuthError, from: location }} />
);
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === "/" || location.pathname === "";
if (isHome) {
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />;
}
+341
View File
@@ -0,0 +1,341 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase, signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
// Import login components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
export default function Login() {
const navigate = useNavigate();
const { session, loading, refreshSession } = useAuth();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMagicLink, setShowMagicLink] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [magicLinkEmail, setMagicLinkEmail] = useState("");
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
const url = new URL(window.location.href);
const emailFromQuery = url.searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
}
} catch (_) {
// ignore
}
}, []);
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t("login.title", "Sign in")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("login.title", "Sign in")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// Show logged in state if authenticated
if (session && !loading) {
return <LoggedInState />;
}
const signInWithProvider = async (
provider: "github" | "google" | "apple" | "azure",
) => {
try {
setIsSigningIn(true);
setError(null);
const redirectTo = absoluteWithBasePath("/auth/callback");
console.log(`[Login] Signing in with ${provider}`);
const oauthOptions: {
redirectTo: string;
queryParams?: Record<string, string>;
} = { redirectTo };
if (provider === "apple") {
oauthOptions.queryParams = { scope: "email name" };
} else if (provider === "azure") {
oauthOptions.queryParams = { scope: "openid profile email" };
} else {
oauthOptions.queryParams = {
access_type: "offline",
prompt: "consent",
};
}
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: oauthOptions,
});
if (error) {
console.error(`[Login] ${provider} error:`, error);
setError(
t("login.failedToSignIn", { provider, message: error.message }),
);
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsSigningIn(false);
}
};
const signInWithEmail = async () => {
if (!email || !password) {
setError(t("login.pleaseEnterBoth"));
return;
}
try {
setIsSigningIn(true);
setError(null);
console.log("[Login] Signing in with email:", email);
const { data, error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password: password,
});
if (error) {
console.error("[Login] Email sign in error:", error);
setError(error.message);
} else if (data.user) {
console.log("[Login] Email sign in successful");
// User will be redirected by the auth state change
}
} catch (err) {
console.error("[Login] Unexpected error]:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsSigningIn(false);
}
};
const signInWithMagicLink = async () => {
if (!magicLinkEmail) {
setError(t("login.pleaseEnterEmail"));
return;
}
try {
setIsSigningIn(true);
setError(null);
console.log("[Login] Sending magic link to:", magicLinkEmail);
const { error } = await supabase.auth.signInWithOtp({
email: magicLinkEmail.trim(),
options: {
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
},
});
if (error) {
console.error("[Login] Magic link error:", error);
setError(error.message);
} else {
setError(null);
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
setMagicLinkEmail("");
setShowMagicLink(false);
}
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsSigningIn(false);
}
};
const handleForgotPassword = () => {
navigate("/auth/reset");
};
const handleAnonymousSignIn = async () => {
try {
setIsSigningIn(true);
setError(null);
console.log("[Login] Signing in anonymously");
const { data } = await signInAnonymously();
if (data.user) {
console.log(
"[Login] Anonymous sign in successful, refreshing session...",
);
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession();
console.log(
"[Login] Session refreshed, user will be redirected by auth state change",
);
// User will be redirected by the auth state change after session refresh
}
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsSigningIn(false);
}
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader
title={t("login.login")}
subtitle={t("login.subtitle", "Sign back in to Stirling PDF")}
/>
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
/>
{/* Divider between OAuth and Email */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm((v) => !v)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t("login.useEmailInstead", "Sign in with email")}
</button>
</div>
{showEmailForm && (
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn ? t("login.loggingIn") : t("login.login")
}
/>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
)}
{/* Divider then Guest */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
label={
isSigningIn
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
<div className="auth-bottom-row">
<button
type="button"
onClick={() => setShowMagicLink(true)}
className="auth-link-black"
>
{t("login.useMagicLink", "Sign in with magic link")}
</button>
<button
type="button"
onClick={() => navigate("/signup")}
className="auth-link-black"
>
{t("signup.signUp", "Sign up")}
</button>
</div>
{/* Magic link form renders on demand */}
{showMagicLink && (
<div style={{ marginTop: "0.5rem" }}>
<MagicLinkForm
showMagicLink={showMagicLink}
magicLinkEmail={magicLinkEmail}
setMagicLinkEmail={setMagicLinkEmail}
setShowMagicLink={setShowMagicLink}
onSubmit={signInWithMagicLink}
isSubmitting={isSigningIn}
/>
</div>
)}
</AuthLayout>
);
}
@@ -0,0 +1,278 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import SuccessMessage from "@app/routes/login/SuccessMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import NavigationLink from "@app/routes/login/NavigationLink";
import { supabase } from "@app/auth/supabase";
import { absoluteWithBasePath } from "@app/constants/app";
import { useTranslation } from "@app/hooks/useTranslation";
export default function ResetPassword() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isRecovery, setIsRecovery] = useState(false);
const [didUpdate, setDidUpdate] = useState(false);
useEffect(() => {
const url = new URL(window.location.href);
const type = url.searchParams.get("type");
const code = url.searchParams.get("code");
// Also parse hash params (Supabase puts tokens & type in the hash)
const hash = url.hash || "";
const hashParams = new URLSearchParams(
hash.startsWith("#") ? hash.substring(1) : hash,
);
const hashType = hashParams.get("type");
const hashError = hashParams.get("error");
const hashErrorDescription = hashParams.get("error_description");
if (hashError) {
// Show a human-readable error and fall back to email-entry form
setError(hashErrorDescription || hashError);
setIsRecovery(false);
}
// Consider either source (query or hash) to decide if we're in recovery mode
const inRecovery = type === "recovery" || hashType === "recovery";
setIsRecovery(inRecovery);
// If a PKCE-style code is present, exchange it for a session immediately
const tryExchange = async () => {
if (code) {
try {
const { data, error } =
await supabase.auth.exchangeCodeForSession(code);
if (error) {
setError(error.message);
setIsRecovery(false);
} else if (data.session) {
setIsRecovery(true);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setIsRecovery(false);
}
} else {
// If no code, see if Supabase already set the session from hash
const { data } = await supabase.auth.getSession();
if (data.session && inRecovery) {
setIsRecovery(true);
}
}
};
void tryExchange();
// Clear sensitive tokens from the URL hash
if (hash.includes("access_token") || hashError) {
window.history.replaceState(
{},
document.title,
window.location.pathname + (inRecovery ? "?type=recovery" : ""),
);
}
// Listen for Supabase auth state changes to confirm recovery state
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
if (event === "PASSWORD_RECOVERY") {
setIsRecovery(true);
}
});
return () => {
sub.subscription.unsubscribe();
};
}, []);
const handleSendEmail = async () => {
if (!email) {
setError(t("login.pleaseEnterEmail"));
return;
}
try {
setIsSubmitting(true);
setError(null);
const redirectTo = absoluteWithBasePath("/auth/reset?type=recovery");
const { error } = await supabase.auth.resetPasswordForEmail(
email.trim(),
{
redirectTo,
},
);
if (error) {
setError(error.message);
} else {
setSuccess(t("login.passwordResetSent", { email }));
}
} finally {
setIsSubmitting(false);
}
};
const handleUpdatePassword = async () => {
if (!password || !confirmPassword) {
setError(t("signup.pleaseFillAllFields"));
return;
}
if (password.length < 6) {
setError(t("signup.passwordTooShort"));
return;
}
if (password !== confirmPassword) {
setError(t("signup.passwordsDoNotMatch"));
return;
}
try {
setIsSubmitting(true);
setError(null);
const { data, error } = await supabase.auth.updateUser({ password });
if (error) {
setError(error.message);
return;
}
if (data.user) {
setSuccess(
t(
"login.passwordUpdatedSuccess",
"Your password has been updated successfully.",
),
);
// Clear the form fields
setPassword("");
setConfirmPassword("");
// Show success-only state and then redirect after a short delay
setDidUpdate(true);
setTimeout(async () => {
const { data: sessionData } = await supabase.auth.getSession();
const { data: userData } = await supabase.auth.getUser();
const derivedEmail = userData.user?.email || email;
if (sessionData.session) {
navigate("/");
} else {
const query = derivedEmail
? `?email=${encodeURIComponent(derivedEmail)}`
: "";
navigate(`/login${query}`);
}
}, 2000);
}
} finally {
setIsSubmitting(false);
}
};
return (
<AuthLayout>
<LoginHeader
title={
isRecovery
? t("login.resetYourPassword", "Reset your password")
: t("login.forgotPassword", "Forgot your password?")
}
/>
{!didUpdate && <SuccessMessage success={success} />}
<ErrorMessage error={error} />
{didUpdate ? (
<>
<SuccessMessage
success={
success ||
t(
"login.passwordUpdatedSuccess",
"Your password has been updated successfully.",
)
}
/>
<NavigationLink
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
) : isRecovery ? (
<>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="password" className="auth-label">
{t("signup.password")}
</label>
<input
id="password"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t("signup.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
/>
</div>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">
{t("signup.confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t("signup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input"
/>
</div>
</div>
<button
onClick={handleUpdatePassword}
disabled={isSubmitting || !password || !confirmPassword}
className="auth-button"
>
{isSubmitting
? t("login.sending", "Sending…")
: t("login.updatePassword", "Update password")}
</button>
<NavigationLink
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
) : (
<>
<EmailPasswordForm
email={email}
password={""}
setEmail={setEmail}
setPassword={() => {}}
onSubmit={handleSendEmail}
isSubmitting={isSubmitting}
submitButtonText={t("login.sendResetLink", "Send reset link")}
showPasswordField={false}
/>
<p className="text-sm text-gray-500 mt-3">
{t(
"login.resetHelp",
"Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.",
)}
</p>
<NavigationLink
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
)}
</AuthLayout>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getBaseUrl } from "@app/constants/app";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import { alert } from "@app/components/toast";
// Import signup components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
export default function Signup() {
const navigate = useNavigate();
const location = useLocation();
const { session, loading, refreshSession } = useAuth();
const { t } = useTranslation();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [name, setName] = useState(undefined as string | undefined);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [agree, setAgree] = useState(true);
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// Check if we were redirected here with an auto-auth error
useEffect(() => {
const state = location.state as { autoAuthError?: string } | null;
if (state?.autoAuthError) {
setError(`Unable to access tool: ${state.autoAuthError}`);
}
}, [location.state]);
// Redirect back to original tool URL once session appears (after auto-anon completes)
useEffect(() => {
if (!loading && session) {
const state = location.state as {
from?: { pathname?: string; search?: string; hash?: string };
} | null;
const from = state?.from;
if (
from?.pathname &&
from.pathname !== "/signup" &&
from.pathname !== "/login"
) {
const target = `${from.pathname}${from.search ?? ""}${from.hash ?? ""}`;
console.log("[Signup] Session detected, redirecting back to:", target);
navigate(target, { replace: true });
}
}
}, [loading, session, location.state, navigate]);
const handleAnonymousSignIn = async () => {
try {
setIsSigningUp(true);
setError(null);
console.log("[Signup] Initiating anonymous sign-in...");
const { data } = await signInAnonymously();
if (data.user) {
console.log(
"[Signup] Anonymous sign-in successful, refreshing session...",
);
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession();
console.log("[Signup] Session refreshed, redirecting to home page");
// Redirect to home page after successful anonymous login and session refresh
navigate("/");
}
} catch (err) {
console.error("[Signup] Anonymous sign-in unexpected error:", err);
setError(
`Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
);
} finally {
setIsSigningUp(false);
}
};
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("signup.title", "Create an account")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
const { validateSignupForm } = useSignupFormValidation();
const { signUp, signInWithProvider } = useAuthService();
const handleSignUp = async () => {
const validation = validateSignupForm(
email,
password,
confirmPassword,
name,
);
if (!validation.isValid) {
setError(validation.error);
setFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSigningUp(true);
setError(null);
setFieldErrors({});
const result = await signUp(email, password, name);
if (result.requiresEmailConfirmation) {
alert({
alertType: "success",
title: t("signup.checkEmailConfirmation"),
location: "top-right",
isPersistentPopup: true,
});
} else {
alert({
alertType: "success",
title: t("signup.accountCreatedSuccessfully"),
location: "top-right",
durationMs: 3000,
});
setTimeout(() => navigate("/login"), 2000);
}
} catch (err) {
console.error("[Signup] Unexpected error:", err);
setError(
err instanceof Error
? err.message
: t("signup.unexpectedError", { message: "Unknown error" }),
);
} finally {
setIsSigningUp(false);
}
};
const handleProviderSignIn = async (
provider: "github" | "google" | "apple" | "azure",
) => {
try {
setIsSigningUp(true);
setError(null);
await signInWithProvider(provider);
} catch (err) {
setError(
err instanceof Error
? err.message
: t("signup.unexpectedError", { message: "Unknown error" }),
);
} finally {
setIsSigningUp(false);
}
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
<ErrorMessage error={error} />
{/* OAuth first */}
<div style={{ marginBottom: "0.5rem" }}>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="fullwidth"
/>
</div>
{/* Divider between OAuth and Email */}
<div style={{ margin: "0.5rem 0" }}>
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
</div>
{/* Use Email Instead button (toggles email form) */}
<div className="auth-section">
<button
type="button"
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t("signup.useEmailInstead", "Use Email Instead")}
</button>
</div>
{showEmailForm && (
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
)}
<div className="auth-section-sm">
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
</div>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
label={
isSigningUp
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
{/* Bottom row */}
<div className="auth-bottom-right">
<button
type="button"
onClick={() => navigate("/login")}
className="auth-link-black"
>
{t("login.logIn", "Log In")}
</button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,75 @@
.authContainer {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--auth-bg-color-light-only);
padding: 1.5rem 1.5rem 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: auto;
}
/* Main area above footer: keep card centered even with footer visible */
.authMain {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 95vh;
}
.authCard {
width: min(45rem, 96vw);
height: min(50.875rem, 96vh);
display: grid;
grid-template-columns: 1fr;
background-color: var(--auth-card-bg);
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
min-height: 0;
max-height: 96vh;
}
.authCardTwoColumns {
width: min(73.75rem, 96vw);
grid-template-columns: 1fr 1fr;
}
.authLeftPanel {
display: flex;
justify-content: center;
padding: 2rem;
min-height: 0;
height: 100%;
}
.authLeftPanelCentered {
align-items: center;
overflow: hidden;
}
.authLeftPanelScrollable {
align-items: flex-start;
overflow-y: auto;
overflow-x: hidden;
}
.authLeftPanel::-webkit-scrollbar {
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
}
.authContent {
max-width: 26.25rem; /* 420px */
width: 100%;
display: flex;
flex-direction: column;
}
.authLeftPanelScrollable .authContent {
min-height: 100%;
}
@@ -0,0 +1,108 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import { useIsOverflowing } from "@app/hooks/useIsOverflowing";
import Footer from "@app/components/shared/Footer";
interface AuthLayoutProps {
children: React.ReactNode;
isEmailFormExpanded?: boolean;
}
export default function AuthLayout({
children,
isEmailFormExpanded = false,
}: AuthLayoutProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement | null>(null);
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
const isOverflowing = useIsOverflowing(leftPanelRef);
// Use either overflow detection or email form expansion to determine scrollable state
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute(
"data-mantine-color-scheme",
);
// Set light mode
htmlElement.setAttribute("data-mantine-color-scheme", "light");
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute(
"data-mantine-color-scheme",
previousColorScheme,
);
}
};
}, []);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<div className={styles.authMain}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
>
<div
ref={leftPanelRef}
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
)}
</div>
</div>
<div
style={{
width: "100vw",
marginTop: "auto",
marginLeft: "-1.5rem",
marginRight: "-1.5rem",
}}
>
<Footer forceLightMode={true} analyticsEnabled />
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import React from "react";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface GuestSignInButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
export default function GuestSignInButton({
label,
onClick,
disabled,
}: GuestSignInButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed auth-guest-button"
>
{label}
</button>
);
}
@@ -0,0 +1,74 @@
/* SaaS-specific auth styles — imported alongside the base auth.css */
.oauth-container-fullwidth {
display: flex;
flex-direction: column;
gap: 0.75rem; /* 12px */
margin-bottom: 0.625rem; /* 10px */
}
.oauth-button-fullwidth {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.625rem;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
color: #000000;
cursor: pointer;
gap: 0.5rem;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
}
.oauth-button-fullwidth:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.auth-dropdown-wrapper {
position: relative;
}
.auth-dropdown-backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 30;
}
.auth-dropdown {
position: absolute;
z-index: 40;
margin-top: 0.5rem;
min-width: 16rem;
background-color: #ffffff;
color: #000000;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
}
.auth-dropdown-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
color: #000;
cursor: pointer;
}
.auth-guest-button {
background-color: #ffffff;
color: #9c2f30;
border: 2px solid currentColor;
}
@@ -0,0 +1,90 @@
import { useTranslation } from "react-i18next";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface EmailPasswordFormProps {
email: string;
password: string;
setEmail: (email: string) => void;
setPassword: (password: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
submitButtonText: string;
showPasswordField?: boolean;
fieldErrors?: {
email?: string;
password?: string;
};
}
export default function EmailPasswordForm({
email,
password,
setEmail,
setPassword,
onSubmit,
isSubmitting,
submitButtonText,
showPasswordField = true,
fieldErrors = {},
}: EmailPasswordFormProps) {
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
};
return (
<form onSubmit={handleSubmit}>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="email" className="auth-label">
{t("login.email", "Email")}
</label>
<input
id="email"
type="email"
name="email"
autoComplete="username email"
placeholder={t("login.enterEmail", "Enter email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`auth-input ${fieldErrors.email ? "auth-input-error" : ""}`}
/>
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div>
{showPasswordField && (
<div className="auth-field">
<label htmlFor="password" className="auth-label">
{t("login.password")}
</label>
<input
id="password"
type="password"
name="current-password"
autoComplete="current-password"
placeholder={t("login.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${fieldErrors.password ? "auth-input-error" : ""}`}
/>
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button"
>
{submitButtonText}
</button>
</form>
);
}
@@ -0,0 +1,22 @@
import { useTranslation } from "@app/hooks/useTranslation";
export default function LoadingState() {
const { t } = useTranslation();
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f3f4f6",
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "32px", marginBottom: "16px" }}></div>
<p style={{ color: "#6b7280" }}>{t("loading")}</p>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useTranslation } from "@app/hooks/useTranslation";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface MagicLinkFormProps {
showMagicLink: boolean;
magicLinkEmail: string;
setMagicLinkEmail: (email: string) => void;
setShowMagicLink: (show: boolean) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export default function MagicLinkForm({
showMagicLink,
magicLinkEmail,
setMagicLinkEmail,
setShowMagicLink,
onSubmit,
isSubmitting,
}: MagicLinkFormProps) {
const { t } = useTranslation();
if (!showMagicLink) {
return (
<div className="auth-toggle-wrapper">
<button
onClick={() => {
setShowMagicLink(true);
}}
disabled={isSubmitting}
className="auth-toggle-link"
>
{t("login.useMagicLink")}
</button>
</div>
);
}
return (
<div className="auth-magic-row">
<input
type="email"
placeholder={t("login.enterEmailForMagicLink")}
value={magicLinkEmail}
onChange={(e) => setMagicLinkEmail(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
className="auth-input"
/>
<button
onClick={onSubmit}
disabled={isSubmitting || !magicLinkEmail}
className="auth-magic-button"
>
{isSubmitting ? t("login.sending") : t("login.sendMagicLink")}
</button>
</div>
);
}
@@ -0,0 +1,127 @@
import { oauthProviders } from "@app/constants/authProviders";
import { useTranslation } from "@app/hooks/useTranslation";
import { Tooltip } from "@app/components/shared/Tooltip";
import { withBasePath } from "@app/constants/app";
// Exports for compatibility with proprietary code
export const DEBUG_SHOW_ALL_PROVIDERS = false;
export const oauthProviderConfig = {
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
apple: { label: "Apple", file: "apple.svg" },
azure: { label: "Microsoft", file: "microsoft.svg" },
};
interface OAuthButtonsProps {
onProviderClick: (provider: "github" | "google") => void;
isSubmitting: boolean;
layout?: "vertical" | "grid" | "icons" | "fullwidth";
enabledProviders?: string[]; // List of enabled provider IDs from backend
}
export default function OAuthButtons({
onProviderClick,
isSubmitting,
layout = "vertical",
enabledProviders: _enabledProviders = [],
}: OAuthButtonsProps) {
const { t } = useTranslation();
if (layout === "icons") {
return (
<div className="oauth-container-icons">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t("login.signInWith", "Sign in with")} ${p.label}`}
position="top"
>
<button
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-icon"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-small ${p.isDisabled ? "opacity-20" : ""}`}
/>
</button>
</Tooltip>
))}
</div>
);
}
if (layout === "grid") {
return (
<div className="oauth-container-grid">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t("login.signInWith", "Sign in with")} ${p.label}`}
position="top"
>
<button
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-grid"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
</button>
</Tooltip>
))}
</div>
);
}
if (layout === "fullwidth") {
return (
<div className="oauth-container-fullwidth">
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-fullwidth"
title={p.label}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
</button>
))}
</div>
);
}
return (
<div className="oauth-container-vertical">
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-vertical"
title={p.label}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-tiny ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,13 @@
interface SuccessMessageProps {
success: string | null;
}
export default function SuccessMessage({ success }: SuccessMessageProps) {
if (!success) return null;
return (
<div className="success-message">
<p className="success-message-text">{success}</p>
</div>
);
}
@@ -0,0 +1,51 @@
import { supabase } from "@app/auth/supabase";
import { absoluteWithBasePath } from "@app/constants/app";
export const useAuthService = () => {
const signUp = async (email: string, password: string, name?: string) => {
console.log("[Signup] Creating account for:", email);
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password: password,
options: {
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
data: { full_name: name },
},
});
if (error) {
console.error("[Signup] Sign up error:", error);
throw new Error(error.message);
}
if (data.user) {
console.log("[Signup] Sign up successful:", data.user);
return {
user: data.user,
session: data.session,
requiresEmailConfirmation: data.user && !data.session,
};
}
throw new Error("Unknown error occurred during signup");
};
const signInWithProvider = async (
provider: "github" | "google" | "apple" | "azure",
) => {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: absoluteWithBasePath("/auth/callback") },
});
if (error) {
throw new Error(error.message);
}
};
return {
signUp,
signInWithProvider,
};
};