mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-14 12:42:08 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user