diff --git a/.env.example b/.env.example
index 88b8b171f7..1f5800fd2f 100644
--- a/.env.example
+++ b/.env.example
@@ -310,6 +310,14 @@ LINKEDIN_CLIENT_ID="sample_linkedin_client_id"
LINKEDIN_CLIENT_SECRET="sample_linkedin_client_secret"
# --- Reddit ---
+# Channel connect goes through the dos.me Reddit OAuth broker (DOS_ME_API_URL,
+# auth key = DOS_ME_INTERNAL_API_KEY), which owns the Reddit app credentials.
+# These two are ONLY used for the refresh grant on 401 token recovery
+# (RedditProvider.refreshToken) - the broker does not cover it. Removing them
+# breaks scheduled Reddit posts older than one hour.
+# Optional override for the broker product label (auto-detected from
+# FRONTEND_URL: beta-post.crove.com -> crove-post-beta, else crove-post-prod).
+# REDDIT_BROKER_PRODUCT="crove-post-beta"
REDDIT_CLIENT_ID="sample_reddit_client_id"
REDDIT_CLIENT_SECRET="sample_reddit_client_secret"
diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx
index 2ac62effbc..5d92d8691d 100644
--- a/apps/frontend/src/components/auth/login.tsx
+++ b/apps/frontend/src/components/auth/login.tsx
@@ -80,7 +80,7 @@ export function Login() {
{isGeneral && genericOauth ? (
-
+
{t(
'sso_description',
diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx
index cf0a560dcd..48e9b84eeb 100644
--- a/apps/frontend/src/components/auth/providers/oauth.provider.tsx
+++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx
@@ -1,15 +1,29 @@
'use client';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import SafeImage from '@gitroom/react/helpers/safe.image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
-export const OauthProvider = () => {
+
+// Session key shared with the auth error state (register.tsx): a freshly
+// initiated SSO flow resets the error-page retry budget.
+export const DOS_OAUTH_RETRY_KEY = 'dos_oauth_retry_count';
+
+// Cap for the automatic redirect only: if we auto-started a flow and are
+// back on an auth page without completing it within 10s (IdP error bounce,
+// remembered-deny, callback without code), show the manual button instead of
+// contributing to a browser/IdP redirect storm. Manual clicks are exempt.
+const DOS_OAUTH_AUTOSTART_TS_KEY = 'dos_oauth_autostart_ts';
+
+export const OauthProvider = ({ autoStart = false }: { autoStart?: boolean }) => {
const fetch = useFetch();
const { oauthLogoUrl, oauthDisplayName } = useVariables();
const t = useT();
- const gotoLogin = useCallback(async () => {
+ const [autoFailed, setAutoFailed] = useState(false);
+ const startedRef = useRef(false);
+
+ const gotoLogin = useCallback(async (): Promise => {
try {
const response = await fetch('/auth/oauth/GENERIC');
if (!response.ok) {
@@ -18,11 +32,63 @@ export const OauthProvider = () => {
);
}
const link = await response.text();
+ // A deliberately initiated SSO flow starts fresh: clear the error-page
+ // retry budget so the user gets their full retry allowance.
+ window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY);
window.location.href = link;
+ return true;
} catch (error) {
console.error('Failed to get generic oauth login link:', error);
+ return false;
}
}, []);
+
+ useEffect(() => {
+ if (!autoStart || startedRef.current) return;
+ startedRef.current = true;
+ const lastStart = Number(
+ window.sessionStorage.getItem(DOS_OAUTH_AUTOSTART_TS_KEY) || '0'
+ );
+ if (Date.now() - lastStart < 10_000) {
+ setAutoFailed(true);
+ return;
+ }
+ window.sessionStorage.setItem(
+ DOS_OAUTH_AUTOSTART_TS_KEY,
+ String(Date.now())
+ );
+ gotoLogin().then((ok) => {
+ if (!ok) {
+ // Auto-start could not even fetch the link - fall back to the
+ // manual button instead of a dead redirecting state.
+ window.sessionStorage.removeItem(DOS_OAUTH_AUTOSTART_TS_KEY);
+ setAutoFailed(true);
+ }
+ });
+ }, [autoStart, gotoLogin]);
+
+ if (autoStart && !autoFailed) {
+ return (
+
(null);
useEffect(() => {
if (code) {
load();
}
}, []);
const load = useCallback(async () => {
+ setError(null);
try {
const response = await fetch(
`/auth/oauth/${provider?.toUpperCase() || 'GENERIC'}/exists`,
@@ -54,21 +59,28 @@ export function Register() {
}
);
if (!response.ok) {
- setShow(true);
+ // The exchange failed server-side. Never masquerade this failure as
+ // a fresh signup: surface it with a loop-guarded retry instead.
+ setError({ status: response.status, message: '' });
return;
}
const data = await response.json();
if (data?.token) {
+ window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY);
setCode(data.token);
setShow(true);
} else {
+ window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY);
window.location.href = '/';
}
} catch (e) {
console.error('Failed to verify oauth code:', e);
- setShow(true);
+ setError({ message: (e as Error)?.message || '' });
}
}, [provider, code, state]);
+ if (error) {
+ return ;
+ }
if (!code && !getQuery?.get('provider')) {
return ;
}
@@ -79,6 +91,74 @@ export function Register() {
);
}
+
+// A failed OAuth exchange (state cookie mismatch, upstream token error, ...)
+// used to fall through to the signup form, so a broken sign-in looked like a
+// fresh registration - the exact confusion reported on 2026-09-21. This state
+// shows what happened and offers a loop-guarded retry: up to RETRY_LIMIT
+// automatic SSO restarts (a live id.dos.me session makes that one click),
+// then a manual link so a persistent failure cannot ping-pong forever.
+const RETRY_LIMIT = 2;
+
+function AuthErrorState({
+ status,
+ message,
+}: {
+ status?: number;
+ message: string;
+}) {
+ const t = useT();
+ const fetch = useFetch();
+ const attempts = Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0');
+ const retry = useCallback(async () => {
+ try {
+ window.sessionStorage.setItem(DOS_OAUTH_RETRY_KEY, String(attempts + 1));
+ const response = await fetch('/auth/oauth/GENERIC');
+ if (response.ok) {
+ window.location.href = await response.text();
+ return;
+ }
+ } catch (e) {
+ console.error('Failed to restart the SSO flow:', e);
+ }
+ window.location.href = '/auth/login';
+ }, [attempts]);
+ return (
+
+
+ {t('sign_in_failed', 'Sign-in failed')}
+
+
+ {t(
+ 'sign_in_failed_body',
+ 'We could not complete your sign-in. This is usually temporary - try again below.'
+ )}
+ {status ? ` (HTTP ${status})` : ''}
+