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;
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 | 🟡 Minor

Unsafe property access — enabledOIDCProviders.length can throw if the property is nullish

configs?.enabledOIDCProviders.length short-circuits to undefined when configs itself is nullish, but if configs is defined while enabledOIDCProviders is null or undefined (e.g. during an in-flight config fetch or a partial response), the unguarded .length access throws a TypeError. The same pattern is repeated on line 93.

🛡️ Proposed fix
-    if (!configs?.enabledOIDCProviders.length) return;
+    if (!configs?.enabledOIDCProviders?.length) return;
-        {configs?.enabledOIDCProviders.length ? (
+        {configs?.enabledOIDCProviders?.length ? (
🤖 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` at line 26, The code access
configs?.enabledOIDCProviders.length can throw if enabledOIDCProviders is
nullish; change both occurrences to safely handle null/undefined (e.g. use
optional chaining or a fallback array) by replacing
configs?.enabledOIDCProviders.length with (configs?.enabledOIDCProviders ??
[]).length or configs?.enabledOIDCProviders?.length, ensuring you update both
the check at the top of the Login component and the repeated check around the
later conditional so enabledOIDCProviders is never dereferenced when
null/undefined.


const provider = configs.enabledOIDCProviders[0];
try {
await authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL,
});
} catch (err) {
setError(String(err));
}
};
Comment on lines +25 to +37
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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the login.tsx file to understand the full context
cat -n client/src/app/invitation/components/login.tsx | head -120

Repository: rybbit-io/rybbit

Length of output: 3757


🏁 Script executed:

# Search for how SocialButtons renders multiple buttons per provider
rg -n "SocialButtons\|providerId\|enabledOIDCProviders" --type=ts --type=tsx -B 2 -A 5 client/

Repository: rybbit-io/rybbit

Length of output: 87


🏁 Script executed:

# Check the PR or commit message to understand the claimed feature
git log --oneline --all -n 50 2>/dev/null | head -20
git show --stat 2>/dev/null | head -100

Repository: rybbit-io/rybbit

Length of output: 7829


🏁 Script executed:

# Find and examine the SocialButtons component
find client -name "*SocialButtons*" -o -name "*social*" | head -20

Repository: rybbit-io/rybbit

Length of output: 104


🏁 Script executed:

# Search for SocialButtons implementation
rg -l "SocialButtons" client/ --type ts

Repository: rybbit-io/rybbit

Length of output: 260


🏁 Script executed:

# Check the auth components directory
ls -la client/components/auth/ 2>/dev/null || echo "Not found, searching..."
fd -t f "SocialButtons" client/

Repository: rybbit-io/rybbit

Length of output: 128


🏁 Script executed:

# Search for how providers are rendered or selected
rg "enabledOIDCProviders|SocialButtons" --type ts -B 3 -A 8 client/

Repository: rybbit-io/rybbit

Length of output: 13437


🏁 Script executed:

# Check git commit message for PR 719 to see claimed features
git log --grep="OIDC\|oauth\|provider" --oneline -n 20 2>/dev/null || git log --all --oneline | grep -i "oidc\|oauth\|provider" | head -10

Repository: rybbit-io/rybbit

Length of output: 42


🏁 Script executed:

# Get full SocialButtons implementation
cat -n client/src/components/auth/SocialButtons.tsx

Repository: rybbit-io/rybbit

Length of output: 3482


🏁 Script executed:

# Check if invitation login.tsx already has SocialButtons rendering on line 71
sed -n '60,110p' client/src/app/invitation/components/login.tsx

Repository: rybbit-io/rybbit

Length of output: 1305


Remove the redundant handleSSOLogin and "Login with SSO" button — SocialButtons already renders all OIDC providers correctly

The SocialButtons component (imported on line 11 and rendered on line 71) already properly handles multiple OIDC providers by mapping over enabledOIDCProviders and rendering one button per provider with the provider's name (see SocialButtons.tsx:59-64).

The separate handleSSOLogin function (lines 25-37) and its associated button (lines 93-102) are redundant—they only use the first provider and create an inconsistent experience. Delete handleSSOLogin and the "Login with SSO" button; let SocialButtons handle all OIDC authentication.

🤖 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 - 37, Remove
the redundant SSO flow: delete the handleSSOLogin function (which calls
authClient.signIn.oauth2 and sets setError) and remove the "Login with SSO"
button JSX so that SocialButtons handles OIDC providers; ensure SocialButtons
(which maps over enabledOIDCProviders) remains imported and rendered, and remove
any now-unused references to handleSSOLogin or the SSO button text while keeping
existing authClient and setError usages elsewhere intact.


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 && (
<>
<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>
Comment on lines +99 to +105
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

Missing SSO-specific loading state and hardcoded UI string violates i18n

Two issues in the SSO button:

  1. isLoading={false} is hardcoded — handleSSOLogin is async and the OAuth2 redirect can take a noticeable moment. There is no loading feedback to the user, and nothing prevents double-clicks.
  2. "Login with SSO" is a raw English string that bypasses t(), violating the project's i18n requirement.

As per coding guidelines: "Use next-intl's 'useTranslations()' hook for i18n".

♻️ Proposed fix

Add a dedicated loading state for SSO:

  const [isLoading, setIsLoading] = useState(false);
+ const [isSSOLoading, setIsSSOLoading] = useState(false);

Update handleSSOLogin:

  const handleSSOLogin = async () => {
    if (!configs?.enabledOIDCProviders?.length) return;
    const provider = configs.enabledOIDCProviders[0];
+   setIsSSOLoading(true);
    try {
      await authClient.signIn.oauth2({
        providerId: provider.providerId,
        callbackURL,
      });
    } catch (err) {
      setError(String(err));
+   } finally {
+     setIsSSOLoading(false);
    }
  };

Update the button:

-           <AuthButton
-             isLoading={false}
-             type="button"
-             variant="default"
-             onClick={handleSSOLogin}
-           >
-             Login with SSO
-           </AuthButton>
+           <AuthButton
+             isLoading={isSSOLoading}
+             type="button"
+             variant="default"
+             onClick={handleSSOLogin}
+           >
+             {t("Login with SSO")}
+           </AuthButton>

Remember to add the key to all translation files (messages/en.json, messages/de.json, etc.).

🤖 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 95 - 101, The
SSO button currently hardcodes isLoading={false} and uses a raw string; add a
dedicated SSO loading state (e.g., const [isSSOLoading, setIsSSOLoading] =
useState(false)), update handleSSOLogin to setIsSSOLoading(true) at start and
setIsSSOLoading(false) on any early returns/errors (or allow redirect to proceed
without resetting), and pass isLoading={isSSOLoading} and
disabled={isSSOLoading} to <AuthButton>; replace the raw "Login with SSO" string
with t('auth.loginWithSSO') using useTranslations(), and add that key to all
messages/*.json translation files.

) : 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];
authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL: "/",
}).catch(err => {
setError(String(err));
});
}
}, [configs, isLoadingConfigs]);
Comment on lines +77 to +87
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

Unsafe enabledOIDCProviders.length access and missing loading indicator in auto-redirect effect

Two issues in this useEffect:

  1. Potential TypeError (line 78): Even though configs is confirmed truthy, configs.enabledOIDCProviders can be undefined if the server omits the field (see the Configs interface — the field is declared required but is API-sourced). The unguarded .length will throw at runtime in that case.

  2. No loading/spinner state during redirect: Once the condition is met, authClient.signIn.oauth2() is called silently. The user sees the otherwise-empty login page (only SocialButtons and the signup link are visible) with no indication that a redirect is in progress. This creates a confusing UX gap.

🛡️ Proposed fix
+  const [isAutoRedirecting, setIsAutoRedirecting] = useState(false);

   useEffect(() => {
-    if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders.length === 1) {
+    if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders?.length === 1) {
       const provider = configs.enabledOIDCProviders[0];
+      setIsAutoRedirecting(true);
       authClient.signIn.oauth2({
         providerId: provider.providerId,
         callbackURL: "/",
       }).catch(err => {
         setError(String(err));
+        setIsAutoRedirecting(false);
       });
     }
   }, [configs, isLoadingConfigs]);

Then render a loading indicator when isAutoRedirecting is true (e.g., replace the form area with a spinner and a "Redirecting to SSO…" message).

📝 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
useEffect(() => {
if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders.length === 1) {
const provider = configs.enabledOIDCProviders[0];
authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL: "/",
}).catch(err => {
setError(String(err));
});
}
}, [configs, isLoadingConfigs]);
const [isAutoRedirecting, setIsAutoRedirecting] = useState(false);
useEffect(() => {
if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders?.length === 1) {
const provider = configs.enabledOIDCProviders[0];
setIsAutoRedirecting(true);
authClient.signIn.oauth2({
providerId: provider.providerId,
callbackURL: "/",
}).catch(err => {
setError(String(err));
setIsAutoRedirecting(false);
});
}
}, [configs, isLoadingConfigs]);
🤖 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 77 - 87, Guard the unsafe access
to configs.enabledOIDCProviders by checking configs.enabledOIDCProviders &&
configs.enabledOIDCProviders.length (or use optional chaining
configs.enabledOIDCProviders?.length) inside the useEffect before reading
.length to avoid a TypeError; add a new state flag (e.g., isAutoRedirecting)
that you set to true immediately before calling authClient.signIn.oauth2(...)
and set to false in the catch handler (or finally) so the UI can show a
loading/spinner; update the component render to display a spinner and
"Redirecting to SSO…" message when isAutoRedirecting is true so users see
feedback during the redirect.


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>
)}

{(!configs?.disableSignup || !isLoadingConfigs) && (
<div className="text-center text-sm">
Expand Down
76 changes: 40 additions & 36 deletions client/src/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,43 +197,47 @@ function SignupPageContent() {
<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={handleAccountSubmit}
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={handleAccountSubmit}
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