Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function Login() {
<div className="flex flex-col">
{isGeneral && genericOauth ? (
<div className="flex flex-col gap-4 mt-2">
<OauthProvider />
<OauthProvider autoStart />
<p className="text-xs text-zinc-400 text-center mt-2 leading-relaxed">
{t(
'sso_description',
Expand Down
72 changes: 69 additions & 3 deletions apps/frontend/src/components/auth/providers/oauth.provider.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
try {
const response = await fetch('/auth/oauth/GENERIC');
if (!response.ok) {
Expand All @@ -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 (
<div
className={`flex w-full items-center justify-center gap-[10px] rounded-[10px] bg-white text-zinc-900 h-[50px] font-semibold text-[15px] shadow-md`}
>
<div className="w-[24px] h-[24px] flex items-center justify-center shrink-0">
<SafeImage
src={oauthLogoUrl || '/icons/generic-oauth.svg'}
alt="DOS ID"
width={24}
height={24}
className="w-[24px] h-[24px] object-contain"
/>
</div>
<div>
{t('redirecting_to', 'Redirecting to')}&nbsp;
{oauthDisplayName || 'DOS ID'}...
</div>
</div>
);
}

return (
<div
onClick={gotoLogin}
Expand Down
88 changes: 84 additions & 4 deletions apps/frontend/src/components/auth/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import clsx from 'clsx';
import { GoogleProvider } from '@gitroom/frontend/components/auth/providers/google.provider';
import { AppleProvider } from '@gitroom/frontend/components/auth/providers/apple.provider';
import { OauthProvider } from '@gitroom/frontend/components/auth/providers/oauth.provider';
import { OauthProvider, DOS_OAUTH_RETRY_KEY } from '@gitroom/frontend/components/auth/providers/oauth.provider';
import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useTrack } from '@gitroom/react/helpers/use.track';
Expand All @@ -36,12 +36,17 @@
const [code, setCode] = useState(getQuery?.get('code') || '');
const [state] = useState(getQuery?.get('state') || '');
const [show, setShow] = useState(false);
const [error, setError] = useState<{
status?: number;
message: string;
} | null>(null);
useEffect(() => {
if (code) {
load();
}
}, []);
const load = useCallback(async () => {
setError(null);
try {
const response = await fetch(
`/auth/oauth/${provider?.toUpperCase() || 'GENERIC'}/exists`,
Expand All @@ -54,21 +59,28 @@
}
);
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 <AuthErrorState status={error.status} message={error.message} />;
}
if (!code && !getQuery?.get('provider')) {
return <RegisterAfter token="" provider="LOCAL" />;
}
Expand All @@ -79,6 +91,74 @@
<RegisterAfter token={code} provider={provider?.toUpperCase() || 'LOCAL'} />
);
}

// 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]);
Comment on lines +113 to +125
return (
<div className="flex flex-1 flex-col justify-center gap-[16px]">
<h1 className="text-[40px] font-[500] -tracking-[0.8px] text-start">
{t('sign_in_failed', 'Sign-in failed')}
</h1>
<p className="text-[14px] leading-relaxed text-zinc-400">
{t(
'sign_in_failed_body',
'We could not complete your sign-in. This is usually temporary - try again below.'
)}
{status ? ` (HTTP ${status})` : ''}
</p>
{!!message && (
<p className="text-[12px] break-all text-red-400">{message}</p>
)}
{attempts < RETRY_LIMIT ? (
<Button type="button" onClick={retry} className="!h-[52px]">
{t('try_again', 'Try again')}
</Button>
) : (
<Link
href="/auth/login"
className="flex h-[52px] cursor-pointer items-center justify-center rounded-[10px] border border-fifth text-[15px] font-semibold"
>
{t('try_again', 'Try again')}
</Link>
)}
<p className="text-center text-sm">
{t('already_have_an_account', 'Already Have An Account?')}&nbsp;
<Link href="/auth/login" className="underline cursor-pointer">
{t('sign_in', 'Sign In')}
</Link>
</p>
</div>
);
}
function getHelpfulReasonForRegistrationFailure(httpCode: number) {
switch (httpCode) {
case 400:
Expand Down Expand Up @@ -172,7 +252,7 @@
<div className="flex flex-col text-[14px]">
{!isAfterProvider && isGeneral && genericOauth ? (
<div className="flex flex-col gap-4 mt-2">
<OauthProvider />
<OauthProvider autoStart />
<p className="text-xs text-zinc-400 text-center mt-2 leading-relaxed">
{t(
'sso_description',
Expand Down
11 changes: 11 additions & 0 deletions apps/frontend/src/components/launches/continue.integration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ export const ContinueIntegration: FC<{
};
}

if (provider === 'reddit') {
// The dos.me Reddit OAuth broker (docs/platform/REDDIT-OAUTH-BROKER.md)
// returns a one-time delivery handle instead of a Reddit authorization
// code; an ?error=... param means the user denied the authorization.
return {
state: searchParams.state || '',
code: searchParams.handle || '',
refresh: searchParams.refresh || '',
};
}

return searchParams;
}, []);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpException, Injectable, Logger } from '@nestjs/common';
import { Provider, User } from '@prisma/client';
import { Provider, Role, User } from '@prisma/client';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { isDosSharedBillingEnabled } from './crove-billing-gate';
import { DosMeBillingClient } from './dos-me-billing.client';
Expand All @@ -16,7 +17,8 @@ export class DosSharedBillingService {

constructor(
private readonly client: DosMeBillingClient,
private readonly subscriptions: SubscriptionService
private readonly subscriptions: SubscriptionService,
private readonly organizations: OrganizationRepository
) {}

enabled() {
Expand All @@ -42,6 +44,21 @@ export class DosSharedBillingService {
return mapDosPlanToCrove('free');
}

// Only the organization OWNER (role SUPERADMIN - the owner role this
// codebase assigns to org creators) may drive the org's subscription
// from their DOS entitlement. A member login - even ADMIN - must never
// clear or downgrade a paid org subscription: clearDosSyncedSubscription
// is deleteMany({ organizationId }) and a free-plan member login wiped
// the JOY org's ULTIMATE subscription on 2026-09-22. Members get a
// read-only view of their own DOS plan instead.
const membership = await this.organizations
.getOrgsByUserId(user.id)
.then((orgs) => orgs.find((o) => o.id === organizationId));
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Fetching all of a user's organizations and then filtering in memory to find their role in a specific organization can be inefficient. This could lead to performance issues for users who are members of a large number of organizations.

A more performant approach would be to add a dedicated method in OrganizationRepository to fetch only the specific organization and the user's role within it.

For example, a method like findUserRoleInOrg(userId: string, organizationId: string): Promise<{ role: Role } | null> could execute a more targeted database query. Here is an example of how that might be implemented in your repository:

// In OrganizationRepository
async findUserRoleInOrg(userId: string, organizationId: string) {
  const org = await this.prisma.organization.findUnique({
    where: { id: organizationId },
    select: {
      users: {
        where: { userId },
        select: { role: true },
      },
    },
  });
  return org?.users?.[0] || null;
}

This would significantly reduce the amount of data fetched from the database and improve the performance of this critical path.

if (membership?.users?.[0]?.role !== Role.SUPERADMIN) {
const entitlement = await this.client.getEntitlement(dosUserId);
return mapDosPlanToCrove(entitlement.plan);
}

const entitlement = await this.client.getEntitlement(dosUserId);
const mapped = mapDosPlanToCrove(entitlement.plan);
const cancelAt = entitlement.current_period_end
Expand Down
Loading
Loading