diff --git a/client/containers/CommunityCreate/CommunityCreate.tsx b/client/containers/CommunityCreate/CommunityCreate.tsx index ab907a3e1..15cf3f8b2 100644 --- a/client/containers/CommunityCreate/CommunityCreate.tsx +++ b/client/containers/CommunityCreate/CommunityCreate.tsx @@ -82,19 +82,10 @@ const CommunityCreatedView = ({ subdomain, hubName }: { subdomain: string; hubNa ); }; -type KFOrg = { - id: string; - name: string; - slug: string; - type: 'personal' | 'shared'; - role: string; -}; - type Props = { hubData?: Hub | null; templates?: CommunityTemplate[]; hubCommunities?: { id: string; title: string; subdomain: string; avatar?: string | null }[]; - kfOrgs?: KFOrg[]; }; const HubBrandedHeader = ({ hub }: { hub: Hub }) => { @@ -118,7 +109,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => { }; const CommunityCreate = (props: Props) => { - const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props; + const { hubData, templates = [], hubCommunities = [] } = props; const { loginData, locationData } = usePageContext(); const altchaRef = useRef(null); const hubSlug = hubData?.slug || locationData?.query?.hub || null; @@ -135,12 +126,6 @@ const CommunityCreate = (props: Props) => { const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [cloneCommunityId, setCloneCommunityId] = useState(null); - // KF org picker: default to personal org, or first available - const personalOrg = kfOrgs.find((o) => o.type === 'personal'); - const [selectedKfOrgId, setSelectedKfOrgId] = useState( - personalOrg?.id ?? kfOrgs[0]?.id ?? null, - ); - const hasHub = !!hubData; const hubAccentDark = hubData?.accentColorDark || '#2D2E2F'; @@ -178,7 +163,6 @@ const CommunityCreate = (props: Props) => { ...(selectedTemplateId === CLONE_MARKER && cloneCommunityId ? { cloneCommunityId } : {}), - ...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}), }); setCreateIsLoading(false); setIsCreated(true); @@ -324,27 +308,6 @@ const CommunityCreate = (props: Props) => { onChange={onDescriptionChange} helperText={`${description.length}/280 characters`} /> - {kfOrgs.length > 1 && ( - -
- -
-
- )} {selectedTemplateId ? ( { {communityData && } - - ); diff --git a/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx b/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx deleted file mode 100644 index cfb270367..000000000 --- a/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; - -import { Button, Callout, Classes } from '@blueprintjs/core'; - -import { apiFetch } from 'client/utils/apiFetch'; -import { SettingsSection } from 'components'; - -type KFOrg = { - id: string; - name: string; - slug: string; - type: 'personal' | 'shared'; - role: string; -}; - -type Props = { - communityData: { - id: string; - title: string; - kfOrgId: string | null; - }; -}; - -const TransferOwnership = (props: Props) => { - const { communityData } = props; - const [orgs, setOrgs] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [isTransferring, setIsTransferring] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const loadOrgs = useCallback(async () => { - try { - const data = await apiFetch.get('/api/kf/my-orgs'); - const fetchedOrgs: KFOrg[] = data.orgs ?? []; - setOrgs(fetchedOrgs); - // Default to current org if set, otherwise first org - if (communityData.kfOrgId && fetchedOrgs.some((o) => o.id === communityData.kfOrgId)) { - setSelectedOrgId(communityData.kfOrgId); - } else if (fetchedOrgs.length > 0) { - setSelectedOrgId(fetchedOrgs[0].id); - } - } catch { - setError('Failed to load organizations'); - } finally { - setLoading(false); - } - }, [communityData.kfOrgId]); - - useEffect(() => { - loadOrgs(); - }, [loadOrgs]); - - const selectedOrg = orgs.find((o) => o.id === selectedOrgId); - const isCurrentOrg = selectedOrgId === communityData.kfOrgId; - - const handleTransfer = async () => { - if (!selectedOrgId || isCurrentOrg) return; - setIsTransferring(true); - setError(null); - setSuccess(null); - try { - await apiFetch.post('/api/kf/transfer-community', { - communityId: communityData.id, - kfOrgId: selectedOrgId, - }); - setSuccess( - `Community transferred to ${selectedOrg?.name ?? 'the selected organization'}.`, - ); - // Update the local state so the button disables - communityData.kfOrgId = selectedOrgId; - } catch (err: any) { - setError(err?.error || err?.message || 'Failed to transfer community'); - } finally { - setIsTransferring(false); - } - }; - - if (loading) { - return ( - -

Loading organizations...

-
- ); - } - - // Need at least 2 orgs to have somewhere to transfer to - if (orgs.length < 2) { - return null; - } - - const currentOrg = orgs.find((o) => o.id === communityData.kfOrgId); - - return ( - -

- Transfer this community to a different KF Account. The target account will become - the billing owner of this community. -

- - {currentOrg && ( -

- Currently owned by: {currentOrg.name} - {currentOrg.type === 'personal' ? ' (Personal)' : ''} -

- )} - - {error && ( - - {error} - - )} - - {success && ( - - {success} - - )} - -
-
-
- -
-
-
-
- ); -}; - -export default TransferOwnership; diff --git a/server/community/model.ts b/server/community/model.ts index c5bd06a7f..9aacb3c5c 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -18,7 +18,6 @@ import { DefaultScope, ForeignKey, HasMany, - Index, Is, IsLowercase, Length, @@ -251,11 +250,6 @@ export class Community extends Model< @Column(DataType.UUID) declare templateId: string | null; - /** KF Auth organization that owns this community (for billing/ownership) */ - @Index - @Column(DataType.TEXT) - declare kfOrgId: string | null; - /** * CMS mode: the community is only visible to members; * public visitors are shown a not-found page with a sign-in prompt. diff --git a/server/community/queries.ts b/server/community/queries.ts index edf4b0a4f..93699b1e4 100644 --- a/server/community/queries.ts +++ b/server/community/queries.ts @@ -105,7 +105,6 @@ export const createCommunity = async ( accentColorDark: inputValues.accentColorDark ?? '#000000', navigation: [{ type: 'page', id: homePageId }], hideCreatePubButton: true, - ...(inputValues.kfOrgId ? { kfOrgId: inputValues.kfOrgId } : {}), }, { actorId: userData.id }, ); diff --git a/server/kf/api.ts b/server/kf/api.ts index 539db97ae..9555770b1 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -10,12 +10,6 @@ docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authoriz * Internal service-to-service endpoints (AUTH_INTERNAL_API_KEY): * POST /api/kf/webhooks — receive webhook events from KF Auth (profile, bans, sessions) * GET /api/kf/branding — return community branding for login page - * GET /api/kf/summary — return community list for a KF org - * GET /api/kf/billing/usage — return usage stats for billing (placeholder) - * - * Session-authenticated endpoints: - * GET /api/kf/my-orgs — return current user's KF Account memberships - * POST /api/kf/transfer-community — transfer community ownership to a different KF Account */ import { timingSafeEqual } from 'crypto'; @@ -26,7 +20,6 @@ import { Collection, Community, Member, Pub, PubAttribution, Release, User } fro import { sequelize } from 'server/sequelize'; import { logout } from 'server/utils/logout'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; -import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; import { isDevelopment, isDuqDuq, isProd } from 'utils/environment'; import { @@ -36,7 +29,6 @@ import { encryptPayload, exchangeCode, fetchUserInfo, - fetchUserOrgs, generateCodeVerifier, OIDC_ISSUER_URL, } from './oidc.server'; @@ -396,134 +388,6 @@ router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => } }); -// ─── Summary API (for KF Account roster / Hub) ────────────────────── - -router.get('/api/kf/summary', requireInternalKey, async (req: any, res: any) => { - try { - const { kf_org_id } = req.query; - if (!kf_org_id) { - return res.status(400).json({ error: 'kf_org_id is required' }); - } - - const communities = await Community.findAll({ - where: { kfOrgId: kf_org_id }, - attributes: ['id', 'title', 'subdomain', 'domain', 'avatar'], - }); - - const accounts = await Promise.all( - communities.map(async (community: any) => { - const [pubCount, memberCount] = await Promise.all([ - Pub.count({ where: { communityId: community.id } }), - Member.count({ - where: { communityId: community.id }, - }), - ]); - - const host = community.domain || `${community.subdomain}.pubpub.org`; - const protocol = isProd() ? 'https' : 'http'; - - return { - id: community.id, - slug: community.subdomain, - type: 'community', - name: community.title, - url: `${protocol}://${host}`, - avatar: community.avatar || null, - stats: { pubs: pubCount, members: memberCount }, - collections: [], - }; - }), - ); - - return res.json({ accounts }); - } catch (err) { - console.error('Summary API error:', err); - return res.status(500).json({ error: 'Internal error' }); - } -}); - -// ─── Billing usage API (placeholder) ───────────────────────────────── - -router.get('/api/kf/billing/usage', requireInternalKey, async (req: any, res: any) => { - try { - const { kf_org_id } = req.query; - if (!kf_org_id) { - return res.status(400).json({ error: 'kf_org_id is required' }); - } - - const communityCount = await Community.count({ - where: { kfOrgId: kf_org_id }, - }); - - // Placeholder — just return community count for now - return res.json({ - kf_org_id, - line_items: [{ key: 'communities', quantity: communityCount }], - }); - } catch (err) { - console.error('Billing usage API error:', err); - return res.status(500).json({ error: 'Internal error' }); - } -}); - -// ─── User's KF orgs (session-authenticated) ───────────────────────── - -router.get('/api/kf/my-orgs', async (req: any, res: any) => { - if (!req.user?.id) { - return res.status(401).json({ error: 'Not authenticated' }); - } - try { - const orgs = await fetchUserOrgs(req.user.id); - return res.json({ orgs }); - } catch (err) { - console.error('Failed to fetch KF orgs:', err); - return res.status(500).json({ error: 'Failed to fetch organizations' }); - } -}); - -// ─── Transfer community ownership ─────────────────────────────────── - -router.post('/api/kf/transfer-community', async (req: any, res: any) => { - if (!req.user?.id) { - return res.status(401).json({ error: 'Not authenticated' }); - } - - const { communityId, kfOrgId } = req.body; - if (!communityId || !kfOrgId) { - return res.status(400).json({ error: 'communityId and kfOrgId are required' }); - } - - try { - // Verify the user is an admin of this community - await ensureUserIsCommunityAdmin({ ...req, id: communityId }); - } catch { - return res.status(403).json({ error: 'You must be an admin of this community' }); - } - - try { - // Verify the user belongs to the target org - const userOrgs = await fetchUserOrgs(req.user.id); - const targetOrg = userOrgs.find((o) => o.id === kfOrgId); - if (!targetOrg) { - return res - .status(403) - .json({ error: 'You are not a member of the target organization' }); - } - - // Update the community's kfOrgId - const [updatedCount] = await Community.update({ kfOrgId }, { where: { id: communityId } }); - - if (updatedCount === 0) { - return res.status(404).json({ error: 'Community not found' }); - } - - return res.json({ success: true, kfOrgId }); - } catch (err) { - console.error('Transfer community error:', err); - return res.status(500).json({ error: 'Internal error' }); - } -}); - // ─── Community detail (for Hubs dashboard) ─────────────────────────── router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, res: any) => { diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 373c52013..03f104144 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -9,16 +9,13 @@ export { decryptPayload, encryptPayload, exchangeCode, - extractOrgs, fetchUserInfo, - fetchUserOrgs, generateCodeChallenge, generateCodeVerifier, initOidc, OIDC_ACCOUNT_URL, OIDC_CLIENT_ID, OIDC_ISSUER_URL, - type OIDCOrg as KFOrg, type OIDCUserInfo as KFUserInfo, REDIRECT_URI, type TokenResponse, diff --git a/server/kf/oidc.server.ts b/server/kf/oidc.server.ts index 06a796e55..6cda83edb 100644 --- a/server/kf/oidc.server.ts +++ b/server/kf/oidc.server.ts @@ -9,7 +9,6 @@ * OIDC_ISSUER_INTERNAL_URL — server-to-server URL for Docker (falls back to OIDC_ISSUER_URL) * OIDC_CLIENT_ID — OAuth client ID * OIDC_CLIENT_SECRET — OAuth client secret - * OIDC_ORGS_CLAIM — custom claim key for org memberships (default: https://knowledgefutures.org/orgs) */ import * as crypto from 'node:crypto'; @@ -24,7 +23,6 @@ const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID ?? 'kf_pubpub'; const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET ?? ''; -const OIDC_ORGS_CLAIM = process.env.OIDC_ORGS_CLAIM ?? 'https://knowledgefutures.org/orgs'; const OIDC_ROLE_CLAIM = process.env.OIDC_ROLE_CLAIM ?? 'https://knowledgefutures.org/role'; const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; @@ -302,14 +300,6 @@ export async function introspectRefreshToken(refreshToken: string): Promise return res.json() as Promise; } -/** Extract org memberships from the userinfo response. */ -export function extractOrgs(userInfo: OIDCUserInfo): OIDCOrg[] { - const orgs = userInfo[OIDC_ORGS_CLAIM]; - if (Array.isArray(orgs)) return orgs as OIDCOrg[]; - return []; -} - // --- Internal API (optional, for KF Auth specific features) --- const AUTH_INTERNAL_API_URL = process.env.AUTH_INTERNAL_API_URL ?? OIDC_ISSUER_INTERNAL_URL; @@ -349,22 +332,6 @@ const AUTH_INTERNAL_API_KEY = process.env.AUTH_INTERNAL_API_KEY ?? ''; /** Whether the internal API is configured and available. */ export const hasInternalApi = Boolean(AUTH_INTERNAL_API_KEY); -/** - * Fetch a user's orgs from the auth provider's internal API. - * Returns empty array if internal API is not configured. - */ -export async function fetchUserOrgs(userId: string): Promise { - if (!AUTH_INTERNAL_API_KEY) return []; - - const res = await fetch(`${AUTH_INTERNAL_API_URL}/api/internal/users/${userId}/orgs`, { - headers: { Authorization: `Bearer ${AUTH_INTERNAL_API_KEY}` }, - }); - - if (!res.ok) return []; - const data = (await res.json()) as { orgs?: OIDCOrg[] }; - return data.orgs ?? []; -} - // --- Outbound ban sync --- export async function syncBanToKfAuth(userId: string, reason?: string): Promise { @@ -416,7 +383,6 @@ export { OIDC_ISSUER_INTERNAL_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, - OIDC_ORGS_CLAIM, OIDC_ROLE_CLAIM, OIDC_ACCOUNT_URL, APP_URL, diff --git a/server/routes/communityCreate.tsx b/server/routes/communityCreate.tsx index cf2d9ba8f..ab3653f50 100644 --- a/server/routes/communityCreate.tsx +++ b/server/routes/communityCreate.tsx @@ -10,7 +10,6 @@ import { getHubWithCommunities, isUserHubManager, } from 'server/hub/queries'; -import { fetchUserOrgs } from 'server/kf/auth'; import { handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { hostIsValid } from 'server/utils/routes'; @@ -28,9 +27,8 @@ router.get('/community/create', (req, res, next) => { return Promise.all([ getInitialData(req), hubSlug ? getHubBySlug(hubSlug) : Promise.resolve(null), - req.user?.id ? fetchUserOrgs(req.user.id) : Promise.resolve([]), ]) - .then(async ([initialData, hubData, kfOrgs]) => { + .then(async ([initialData, hubData]) => { const templates = hubData ? await getActiveTemplatesForHub(hubData.id) : []; // Fetch hub communities for the clone-from-community picker @@ -83,7 +81,7 @@ router.get('/community/create', (req, res, next) => { { + await sequelize.query('DROP INDEX IF EXISTS "communities_kf_org_id_idx"'); + await sequelize.query('ALTER TABLE "Communities" DROP COLUMN IF EXISTS "kfOrgId"'); +}; + +export const down = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.addColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: true, + }); + await sequelize.queryInterface.addIndex('Communities', ['kfOrgId'], { + name: 'communities_kf_org_id_idx', + }); +}; diff --git a/utils/api/schemas/community.ts b/utils/api/schemas/community.ts index e94b57576..2d3033516 100644 --- a/utils/api/schemas/community.ts +++ b/utils/api/schemas/community.ts @@ -100,7 +100,6 @@ export const communitySchema = baseSchema.extend({ spamTagId: z.string().uuid().nullable(), scopeSummaryId: z.string().uuid().nullable(), templateId: z.string().uuid().nullable(), - kfOrgId: z.string().nullable(), cmsMode: z.boolean().default(false), canonicalBaseUrl: z.string().url().nullable(), canonicalPubUrlTemplate: z @@ -142,7 +141,6 @@ export const communityCreateSchema = communitySchema altcha: z.string().optional(), _honeypot: z.string().optional(), templateId: z.string().uuid().nullish(), - kfOrgId: z.string().nullish(), }); export const communityUpdateSchema = communitySchema