Skip to content
Closed
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
32 changes: 16 additions & 16 deletions client/containers/CommunityCreate/CommunityCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const CommunityCreatedView = ({ subdomain, hubName }: { subdomain: string; hubNa
);
};

type KFOrg = {
type KFAccount = {
id: string;
name: string;
slug: string;
Expand All @@ -94,7 +94,7 @@ type Props = {
hubData?: Hub | null;
templates?: CommunityTemplate[];
hubCommunities?: { id: string; title: string; subdomain: string; avatar?: string | null }[];
kfOrgs?: KFOrg[];
kfAccounts?: KFAccount[];
};

const HubBrandedHeader = ({ hub }: { hub: Hub }) => {
Expand All @@ -118,7 +118,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => {
};

const CommunityCreate = (props: Props) => {
const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props;
const { hubData, templates = [], hubCommunities = [], kfAccounts = [] } = props;
const { loginData, locationData } = usePageContext();
const altchaRef = useRef<import('components').AltchaRef>(null);
const hubSlug = hubData?.slug || locationData?.query?.hub || null;
Expand All @@ -135,10 +135,10 @@ const CommunityCreate = (props: Props) => {
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [cloneCommunityId, setCloneCommunityId] = useState<string | null>(null);

// KF org picker: default to personal org, or first available
const personalOrg = kfOrgs.find((o) => o.type === 'personal');
const [selectedKfOrgId, setSelectedKfOrgId] = useState<string | null>(
personalOrg?.id ?? kfOrgs[0]?.id ?? null,
// KF account picker: default to the personal account, or first available
const personalAccount = kfAccounts.find((a) => a.type === 'personal');
const [selectedKfAccountId, setSelectedKfAccountId] = useState<string | null>(
personalAccount?.id ?? kfAccounts[0]?.id ?? null,
);

const hasHub = !!hubData;
Expand Down Expand Up @@ -178,7 +178,7 @@ const CommunityCreate = (props: Props) => {
...(selectedTemplateId === CLONE_MARKER && cloneCommunityId
? { cloneCommunityId }
: {}),
...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}),
...(selectedKfAccountId ? { kfAccountId: selectedKfAccountId } : {}),
});
setCreateIsLoading(false);
setIsCreated(true);
Expand Down Expand Up @@ -324,19 +324,19 @@ const CommunityCreate = (props: Props) => {
onChange={onDescriptionChange}
helperText={`${description.length}/280 characters`}
/>
{kfOrgs.length > 1 && (
<InputField label="Organization">
{kfAccounts.length > 1 && (
<InputField label="KF Account">
<div className={Classes.HTML_SELECT}>
<select
value={selectedKfOrgId ?? ''}
value={selectedKfAccountId ?? ''}
onChange={(e) =>
setSelectedKfOrgId(e.target.value || null)
setSelectedKfAccountId(e.target.value || null)
}
>
{kfOrgs.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
{org.type === 'personal'
{kfAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{account.type === 'personal'
? ' (Personal)'
: ''}
</option>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button, Callout, Classes } from '@blueprintjs/core';
import { apiFetch } from 'client/utils/apiFetch';
import { SettingsSection } from 'components';

type KFOrg = {
type KFAccount = {
id: string;
name: string;
slug: string;
Expand All @@ -17,59 +17,62 @@ type Props = {
communityData: {
id: string;
title: string;
kfOrgId: string | null;
kfAccountId: string | null;
};
};

const TransferOwnership = (props: Props) => {
const { communityData } = props;
const [orgs, setOrgs] = useState<KFOrg[]>([]);
const [accounts, setAccounts] = useState<KFAccount[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
const [isTransferring, setIsTransferring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);

const loadOrgs = useCallback(async () => {
const loadAccounts = 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);
const fetchedAccounts: KFAccount[] = data.orgs ?? [];
setAccounts(fetchedAccounts);
// Default to the current account if set, otherwise the first one
if (
communityData.kfAccountId &&
fetchedAccounts.some((a) => a.id === communityData.kfAccountId)
) {
setSelectedAccountId(communityData.kfAccountId);
} else if (fetchedAccounts.length > 0) {
setSelectedAccountId(fetchedAccounts[0].id);
}
} catch {
setError('Failed to load organizations');
setError('Failed to load accounts');
} finally {
setLoading(false);
}
}, [communityData.kfOrgId]);
}, [communityData.kfAccountId]);

useEffect(() => {
loadOrgs();
}, [loadOrgs]);
loadAccounts();
}, [loadAccounts]);

const selectedOrg = orgs.find((o) => o.id === selectedOrgId);
const isCurrentOrg = selectedOrgId === communityData.kfOrgId;
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
const isCurrentAccount = selectedAccountId === communityData.kfAccountId;

const handleTransfer = async () => {
if (!selectedOrgId || isCurrentOrg) return;
if (!selectedAccountId || isCurrentAccount) return;
setIsTransferring(true);
setError(null);
setSuccess(null);
try {
await apiFetch.post('/api/kf/transfer-community', {
communityId: communityData.id,
kfOrgId: selectedOrgId,
kfAccountId: selectedAccountId,
});
setSuccess(
`Community transferred to ${selectedOrg?.name ?? 'the selected organization'}.`,
`Community transferred to ${selectedAccount?.name ?? 'the selected account'}.`,
);
// Update the local state so the button disables
communityData.kfOrgId = selectedOrgId;
communityData.kfAccountId = selectedAccountId;
} catch (err: any) {
setError(err?.error || err?.message || 'Failed to transfer community');
} finally {
Expand All @@ -80,17 +83,17 @@ const TransferOwnership = (props: Props) => {
if (loading) {
return (
<SettingsSection title="Transfer Ownership">
<p className={Classes.TEXT_MUTED}>Loading organizations...</p>
<p className={Classes.TEXT_MUTED}>Loading accounts...</p>
</SettingsSection>
);
}

// Need at least 2 orgs to have somewhere to transfer to
if (orgs.length < 2) {
// Need at least 2 accounts to have somewhere to transfer to
if (accounts.length < 2) {
return null;
}

const currentOrg = orgs.find((o) => o.id === communityData.kfOrgId);
const currentAccount = accounts.find((a) => a.id === communityData.kfAccountId);

return (
<SettingsSection title="Transfer Ownership">
Expand All @@ -99,10 +102,10 @@ const TransferOwnership = (props: Props) => {
the billing owner of this community.
</p>

{currentOrg && (
{currentAccount && (
<p>
Currently owned by: <strong>{currentOrg.name}</strong>
{currentOrg.type === 'personal' ? ' (Personal)' : ''}
Currently owned by: <strong>{currentAccount.name}</strong>
{currentAccount.type === 'personal' ? ' (Personal)' : ''}
</p>
)}

Expand All @@ -122,18 +125,18 @@ const TransferOwnership = (props: Props) => {
<div style={{ flex: 1, maxWidth: 300 }}>
<div className={Classes.HTML_SELECT} style={{ width: '100%' }}>
<select
value={selectedOrgId ?? ''}
value={selectedAccountId ?? ''}
onChange={(e) => {
setSelectedOrgId(e.target.value || null);
setSelectedAccountId(e.target.value || null);
setSuccess(null);
}}
disabled={isTransferring}
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
{org.type === 'personal' ? ' (Personal)' : ''}
{org.id === communityData.kfOrgId ? ' (current)' : ''}
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{account.type === 'personal' ? ' (Personal)' : ''}
{account.id === communityData.kfAccountId ? ' (current)' : ''}
</option>
))}
</select>
Expand All @@ -143,7 +146,7 @@ const TransferOwnership = (props: Props) => {
intent="warning"
text="Transfer"
loading={isTransferring}
disabled={isCurrentOrg || !selectedOrgId}
disabled={isCurrentAccount || !selectedAccountId}
onClick={handleTransfer}
/>
</div>
Expand Down
44 changes: 43 additions & 1 deletion server/community/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ const models = modelize`
}
`;

const { subscribeUser, postToSlackAboutNewCommunity } = vi.hoisted(() => {
const { subscribeUser, postToSlackAboutNewCommunity, fetchUserOrgs } = vi.hoisted(() => {
return {
subscribeUser: vi.fn(),
postToSlackAboutNewCommunity: vi.fn(),
fetchUserOrgs: vi.fn(async () => [] as { id: string }[]),
};
});

Expand All @@ -39,6 +40,10 @@ setup(beforeAll, async () => {
vi.mock('server/utils/slack', () => ({
postToSlackAboutNewCommunity,
}));
vi.mock('server/kf/oidc.server', async (importOriginal) => ({
...(await importOriginal<typeof import('server/kf/oidc.server')>()),
fetchUserOrgs,
}));

await models.resolve();
});
Expand Down Expand Up @@ -116,6 +121,43 @@ describe('/api/communities', () => {
expect(newCommunity?.title).toEqual('Journal of Regular Users');
});

it('creates a community with no kfAccountId when the user has no KF accounts', async () => {
const { willNotCreateCommunity } = models;
const agent = await login(willNotCreateCommunity);
const subdomain = 'no-accounts-' + uuid.v4();
await agent
.post('/api/communities')
.send({ subdomain, title: 'Journal of No Accounts' })
.expect(201);
const newCommunity = await Community.findOne({ where: { subdomain } });
expect(newCommunity?.kfAccountId).toBeNull();
});

it('keeps a picked kfAccountId only if the user belongs to that account', async () => {
const { willNotCreateCommunity } = models;
const agent = await login(willNotCreateCommunity);
fetchUserOrgs.mockResolvedValue([{ id: 'account-mine' }]);

const mine = 'account-mine-' + uuid.v4();
await agent
.post('/api/communities')
.send({ subdomain: mine, title: 'Mine', kfAccountId: 'account-mine' })
.expect(201);
expect((await Community.findOne({ where: { subdomain: mine } }))?.kfAccountId).toEqual(
'account-mine',
);

const theirs = 'account-theirs-' + uuid.v4();
await agent
.post('/api/communities')
.send({ subdomain: theirs, title: 'Theirs', kfAccountId: 'account-theirs' })
.expect(201);
expect((await Community.findOne({ where: { subdomain: theirs } }))?.kfAccountId).toBeNull();

fetchUserOrgs.mockReset();
fetchUserOrgs.mockResolvedValue([]);
});

it('does not create a community if you are logged out', async () => {
await (await login())
.post('/api/communities')
Expand Down
9 changes: 9 additions & 0 deletions server/community/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { initServer } from '@ts-rest/express';
import { Op } from 'sequelize';

import { setSubdomain } from 'server/dev/api';
import { fetchUserOrgs } from 'server/kf/oidc.server';
import { Community, User, WorkerTask } from 'server/models';
import { updateDiscussionCreationAccess } from 'server/publicPermissions/queries';
import { verifyCaptchaPayload } from 'server/utils/captcha';
Expand Down Expand Up @@ -155,6 +156,14 @@ export const communityServer = s.router(contract.community, {
const body = { ...req.body };
delete body.altcha;
delete body._honeypot;
// kfAccountId is billing attribution, not required ownership. Keep a picked
// account only if the user belongs to it; otherwise leave it NULL for staff to set.
if (body.kfAccountId) {
const userAccounts = await fetchUserOrgs(req.user.id).catch(() => []);
if (!userAccounts.some((account) => account.id === body.kfAccountId)) {
delete body.kfAccountId;
}
}
try {
// Resolve hub context before creating the community
const hubSlug = (req as any).body.hubSlug as string | undefined;
Expand Down
13 changes: 9 additions & 4 deletions server/community/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
DefaultScope,
ForeignKey,
HasMany,
Index,
Is,
IsLowercase,
Length,
Expand Down Expand Up @@ -251,10 +250,16 @@ export class Community extends Model<
@Column(DataType.UUID)
declare templateId: string | null;

/** KF Auth organization that owns this community (for billing/ownership) */
@Index
/**
* KF account this community is billed to. Billing attribution set by KF staff,
* not ownership: NULL until someone assigns it (users who sign up through
* kf-console have no personal account to default to). Was kfOrgId until
* 2026_10_05_renameKfOrgIdToKfAccountId. Its index (communities_kf_account_id_idx)
* is owned by the migrations, not declared here: sync() would otherwise try to
* create it on kfAccountId before the rename migration has run.
*/
@Column(DataType.TEXT)
declare kfOrgId: string | null;
declare kfAccountId: string | null;

/**
* CMS mode: the community is only visible to members;
Expand Down
2 changes: 1 addition & 1 deletion server/community/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const createCommunity = async (
accentColorDark: inputValues.accentColorDark ?? '#000000',
navigation: [{ type: 'page', id: homePageId }],
hideCreatePubButton: true,
...(inputValues.kfOrgId ? { kfOrgId: inputValues.kfOrgId } : {}),
...(inputValues.kfAccountId ? { kfAccountId: inputValues.kfAccountId } : {}),
},
{ actorId: userData.id },
);
Expand Down
Loading
Loading