Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ See how Rybbit compares to other analytics solutions:
| **Error Tracking** | ✅ | ❌ | ❌ | ❌ |
| **Public Dashboards** | ✅ | ❌ | ✅ | ❌ |
| **Organizations** | ✅ | ✅ | ✅ | ✅ |
| **SSO / OpenID Connect** | ✅ | ✅ | ✅ | ✅ |
| **Free Tier** | ✅ | ✅ | ❌ | ✅ |
| **Frog 🐸** | ✅ | ❌ | ❌ | ❌ |

Expand Down
72 changes: 51 additions & 21 deletions client/src/app/invitation/components/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useExtracted } from "next-intl";
import { useState } from "react";
import { authClient } from "../../../lib/auth";
import { userStore } from "../../../lib/userStore";
import { useConfigs } from "../../../lib/configs";
import { AuthInput } from "@/components/auth/AuthInput";
import { AuthButton } from "@/components/auth/AuthButton";
import { AuthError } from "@/components/auth/AuthError";
Expand All @@ -19,6 +20,21 @@ export function Login({ callbackURL }: LoginProps) {
const [error, setError] = useState<string>("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { configs } = useConfigs();

const handleSSOLogin = async () => {
if (!configs?.enabledOIDCProviders.length) return;

const provider = configs.enabledOIDCProviders[0];
try {
await authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL,
});
Comment on lines +25 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SSO button always picks the first provider, which is unsafe for multi-provider configs.

At Line 28-32 and Line 97-105, users get a generic “Login with SSO” button, but the implementation always authenticates with enabledOIDCProviders[0]. In multi-provider setups, this can direct users to the wrong IdP.

Suggested fix
-  const handleSSOLogin = async () => {
-    if (!configs?.enabledOIDCProviders.length) return;
-
-    const provider = configs.enabledOIDCProviders[0];
+  const oidcProviders = configs?.enabledOIDCProviders ?? [];
+  const handleSSOLogin = async (providerId: string) => {
     try {
       await authClient.signIn.oauth2({
-        providerId: provider.providerId,
+        providerId,
         callbackURL,
       });
     } catch (err) {
       setError(String(err));
     }
   };
@@
-        {configs?.enabledOIDCProviders.length ? (
-          <AuthButton
-            isLoading={false}
-            type="button"
-            variant="default"
-            onClick={handleSSOLogin}
-          >
-            Login with SSO
-          </AuthButton>
-        ) : null}
+        {oidcProviders.map(provider => (
+          <AuthButton
+            key={provider.providerId}
+            isLoading={false}
+            type="button"
+            variant="default"
+            onClick={() => handleSSOLogin(provider.providerId)}
+          >
+            {provider.name}
+          </AuthButton>
+        ))}

Also applies to: 97-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/invitation/components/login.tsx` around lines 25 - 33, The SSO
flow always uses the first entry in configs.enabledOIDCProviders, causing
incorrect IdP selection for multi-provider setups; update the UI and logic
(e.g., handleSSOLogin and wherever the “Login with SSO” button is rendered) to
allow selecting the correct provider: present a provider chooser when
configs.enabledOIDCProviders.length > 1 (dropdown, menu, or per-provider
buttons), pass the chosen provider.providerId into
authClient.signIn.oauth2(callbackURL) instead of using enabledOIDCProviders[0],
and ensure any other SSO trigger (the code around the second occurrence
described) uses the same chosen provider value.

} catch (err) {
setError(String(err));
}
};

const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -53,27 +69,41 @@ export function Login({ callbackURL }: LoginProps) {
<form onSubmit={handleLogin}>
<div className="flex flex-col gap-4">
<SocialButtons onError={setError} callbackURL={callbackURL} />
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
/>
<AuthButton isLoading={isLoading} loadingText={t("Logging in...")}>
{t("Login to Accept Invitation")}
</AuthButton>
{configs?.internalAuthEnabled && (
<>
Comment on lines +72 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Internal auth should default to enabled when config is unset.

At Line 72, gating on configs?.internalAuthEnabled can hide credential login unless config is explicitly loaded with true. This weakens the intended default behavior.

Suggested fix
 export function Login({ callbackURL }: LoginProps) {
@@
   const { configs } = useConfigs();
+  const internalAuthEnabled = configs?.internalAuthEnabled ?? true;
@@
-        {configs?.internalAuthEnabled && (
+        {internalAuthEnabled && (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{configs?.internalAuthEnabled && (
<>
export function Login({ callbackURL }: LoginProps) {
const { configs } = useConfigs();
const internalAuthEnabled = configs?.internalAuthEnabled ?? true;
// ... other code ...
{internalAuthEnabled && (
<>
{/* internal auth form content */}
</>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/invitation/components/login.tsx` around lines 72 - 73, The
conditional rendering currently uses configs?.internalAuthEnabled which hides
the internal credential login when the config is missing; change the check to
treat undefined as enabled (e.g., use the nullish-coalescing or explicit !==
false pattern) so the block that renders the credential login (the JSX guarded
by configs?.internalAuthEnabled) shows by default; update the condition around
that fragment in the login.tsx component to use configs?.internalAuthEnabled ??
true (or configs?.internalAuthEnabled !== false) so only an explicit false
disables it.

<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
/>
<AuthButton isLoading={isLoading} loadingText={t("Logging in...")}>
{t("Login to Accept Invitation")}
</AuthButton>
</>
)}
{configs?.enabledOIDCProviders.length ? (
<AuthButton
isLoading={false}
type="button"
variant="default"
onClick={handleSSOLogin}
>
Login with SSO
</AuthButton>
) : null}
<AuthError error={error} />
</div>
</form>
Expand Down
109 changes: 62 additions & 47 deletions client/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Turnstile } from "@/components/auth/Turnstile";
import { useExtracted } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { RybbitTextLogo } from "../../components/RybbitLogo";
import { SpinningGlobe } from "../../components/SpinningGlobe";
import { useSetPageTitle } from "../../hooks/useSetPageTitle";
Expand Down Expand Up @@ -73,6 +73,19 @@ export default function Page() {

const turnstileEnabled = IS_CLOUD && process.env.NODE_ENV === "production";

// Auto-redirect to SSO if internal auth is disabled and there's only one OIDC provider
useEffect(() => {
if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders.length === 1) {
const provider = configs.enabledOIDCProviders[0];
Comment on lines +78 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Default-to-enabled auth behavior is not preserved in the UI.

At Line 78 and Line 103, the checks rely on truthiness (!configs.internalAuthEnabled / configs?.internalAuthEnabled) instead of explicit defaulting. If internalAuthEnabled is unset, this can hide email/password auth and alter redirect behavior.

Suggested fix
 export default function Page() {
   const { configs, isLoading: isLoadingConfigs } = useConfigs();
+  const internalAuthEnabled = configs?.internalAuthEnabled ?? true;
+  const enabledOidcProviders = configs?.enabledOIDCProviders ?? [];
@@
-  useEffect(() => {
-    if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders.length === 1) {
-      const provider = configs.enabledOIDCProviders[0];
+  useEffect(() => {
+    if (!isLoadingConfigs && configs && internalAuthEnabled === false && enabledOidcProviders.length === 1) {
+      const provider = enabledOidcProviders[0];
       authClient.signIn.oauth2({
         providerId: provider.providerId,
         callbackURL: "/",
       }).catch(err => {
         setError(String(err));
       });
     }
-  }, [configs, isLoadingConfigs]);
+  }, [configs, isLoadingConfigs, internalAuthEnabled, enabledOidcProviders]);
@@
-            {configs?.internalAuthEnabled && (
+            {internalAuthEnabled && (

Also applies to: 103-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/login/page.tsx` around lines 78 - 79, The UI currently treats
an unset configs.internalAuthEnabled as false due to truthy checks, hiding
email/password auth; update both conditionals that reference
configs.internalAuthEnabled to explicitly default to true when undefined (use
the nullish coalescing operator). For example, replace occurrences like
!configs.internalAuthEnabled with !(configs.internalAuthEnabled ?? true) and
replace configs?.internalAuthEnabled with configs?.internalAuthEnabled ?? true
so the Login page logic (the conditional around enabledOIDCProviders and the
later redirect/display logic) preserves default-to-enabled behavior.

authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL: "/",
}).catch(err => {
setError(String(err));
});
}
}, [configs, isLoadingConfigs]);

return (
<div className="flex h-dvh w-full">
{/* Left panel - login form */}
Expand All @@ -87,55 +100,57 @@ export default function Page() {
<h1 className="text-lg text-neutral-600 dark:text-neutral-300 mb-6">{t("Welcome back")}</h1>
<div className="flex flex-col gap-4">
<SocialButtons onError={setError} />
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>

<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
rightElement={
IS_CLOUD && (
<Link href="/reset-password" className="text-xs text-muted-foreground hover:text-primary">
{t("Forgot password?")}
</Link>
)
}
/>

{turnstileEnabled && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
{configs?.internalAuthEnabled && (
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
)}

<AuthButton
isLoading={isLoading}
loadingText={t("Logging in...")}
disabled={turnstileEnabled ? !turnstileToken || isLoading : isLoading}
>
{t("Login")}
</AuthButton>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
rightElement={
IS_CLOUD && (
<Link href="/reset-password" className="text-xs text-muted-foreground hover:text-primary">
{t("Forgot password?")}
</Link>
)
}
/>

<AuthError error={error} title={t("Error Logging In")} />
</div>
</form>
{turnstileEnabled && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
/>
)}

<AuthButton
isLoading={isLoading}
loadingText={t("Logging in...")}
disabled={turnstileEnabled ? !turnstileToken || isLoading : isLoading}
>
{t("Login")}
</AuthButton>

<AuthError error={error} title={t("Error Logging In")} />
</div>
</form>
)}
Comment on lines +103 to +153
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Auth errors are hidden when internal auth is disabled.

AuthError (Line 150) is inside the internal-auth-only block. If SSO/social login fails while internal auth is off, users won’t see the error message.

Suggested fix
-            {configs?.internalAuthEnabled && (
+            {configs?.internalAuthEnabled && (
               <form onSubmit={handleSubmit}>
                 <div className="flex flex-col gap-4">
@@
-                  <AuthError error={error} title={t("Error Logging In")} />
                 </div>
               </form>
             )}
+            <AuthError error={error} title={t("Error Logging In")} />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{configs?.internalAuthEnabled && (
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
)}
<AuthButton
isLoading={isLoading}
loadingText={t("Logging in...")}
disabled={turnstileEnabled ? !turnstileToken || isLoading : isLoading}
>
{t("Login")}
</AuthButton>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
rightElement={
IS_CLOUD && (
<Link href="/reset-password" className="text-xs text-muted-foreground hover:text-primary">
{t("Forgot password?")}
</Link>
)
}
/>
<AuthError error={error} title={t("Error Logging In")} />
</div>
</form>
{turnstileEnabled && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
/>
)}
<AuthButton
isLoading={isLoading}
loadingText={t("Logging in...")}
disabled={turnstileEnabled ? !turnstileToken || isLoading : isLoading}
>
{t("Login")}
</AuthButton>
<AuthError error={error} title={t("Error Logging In")} />
</div>
</form>
)}
{configs?.internalAuthEnabled && (
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="example@email.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
rightElement={
IS_CLOUD && (
<Link href="/reset-password" className="text-xs text-muted-foreground hover:text-primary">
{t("Forgot password?")}
</Link>
)
}
/>
{turnstileEnabled && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
/>
)}
<AuthButton
isLoading={isLoading}
loadingText={t("Logging in...")}
disabled={turnstileEnabled ? !turnstileToken || isLoading : isLoading}
>
{t("Login")}
</AuthButton>
</div>
</form>
)}
<AuthError error={error} title={t("Error Logging In")} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/login/page.tsx` around lines 103 - 153, AuthError is currently
rendered only when configs?.internalAuthEnabled is true, so SSO/social login
errors are hidden when internal auth is disabled; move the <AuthError
error={error} title={t("Error Logging In")} /> component out of the
internal-auth-only conditional (render it alongside or below the social/SSO
login UI) so it always displays regardless of configs?.internalAuthEnabled,
ensuring it still receives the same error and title props and that any state
variables (error) used by handleSubmit and SSO handlers remain in scope.


{(!configs?.disableSignup || !isLoadingConfigs) && (
<div className="text-center text-sm">
Expand Down
78 changes: 42 additions & 36 deletions client/src/app/signup/components/AccountStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ArrowRight } from "lucide-react";
import { useExtracted } from "next-intl";
import Link from "next/link";

import { useConfigs } from "../../../lib/configs";
import { IS_CLOUD } from "../../../lib/const";

interface AccountStepProps {
Expand All @@ -32,49 +33,54 @@ export function AccountStep({
setError,
}: AccountStepProps) {
const t = useExtracted();
const { configs } = useConfigs();

return (
<div>
<h2 className="text-2xl font-semibold mb-4">{t("Signup")}</h2>
<div className="space-y-4">
<SocialButtons onError={setError} callbackURL="/signup?step=2" mode="signup" />
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="email@example.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
/>
{IS_CLOUD && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
/>
{configs?.internalAuthEnabled && (
<>
<AuthInput
id="email"
label={t("Email")}
type="email"
placeholder="email@example.com"
required
value={email}
onChange={e => setEmail(e.target.value)}
/>
<AuthInput
id="password"
label={t("Password")}
type="password"
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
/>
{IS_CLOUD && (
<Turnstile
onSuccess={token => setTurnstileToken(token)}
onError={() => setTurnstileToken("")}
onExpire={() => setTurnstileToken("")}
className="flex justify-center"
/>
)}
<AuthButton
isLoading={isLoading}
loadingText={t("Creating account...")}
onClick={onSubmit}
type="button"
className="mt-6 transition-all duration-300 h-11"
disabled={IS_CLOUD ? !turnstileToken || isLoading : isLoading}
>
{t("Continue")}
<ArrowRight className="ml-2 h-4 w-4" />
</AuthButton>
</>
)}
<AuthButton
isLoading={isLoading}
loadingText={t("Creating account...")}
onClick={onSubmit}
type="button"
className="mt-6 transition-all duration-300 h-11"
disabled={IS_CLOUD ? !turnstileToken || isLoading : isLoading}
>
{t("Continue")}
<ArrowRight className="ml-2 h-4 w-4" />
</AuthButton>
<div className="text-center text-sm">
{t("Already have an account?")}{" "}
<Link
Expand Down
Loading