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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ yarn-error.log*
.env.*
!.env.enc
!.env.dev.enc
!.env.local.enc
!infra/.env.test


Expand Down Expand Up @@ -71,3 +72,5 @@ tsconfig.tsbuildinfo

infra/pgdata/
tmp/

planning/
39 changes: 38 additions & 1 deletion client/containers/CommunityCreate/CommunityCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,19 @@ 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 }) => {
Expand All @@ -109,7 +118,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => {
};

const CommunityCreate = (props: Props) => {
const { hubData, templates = [], hubCommunities = [] } = props;
const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props;
const { loginData, locationData } = usePageContext();
const altchaRef = useRef<import('components').AltchaRef>(null);
const hubSlug = hubData?.slug || locationData?.query?.hub || null;
Expand All @@ -126,6 +135,12 @@ 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,
);

const hasHub = !!hubData;
const hubAccentDark = hubData?.accentColorDark || '#2D2E2F';

Expand Down Expand Up @@ -163,6 +178,7 @@ const CommunityCreate = (props: Props) => {
...(selectedTemplateId === CLONE_MARKER && cloneCommunityId
? { cloneCommunityId }
: {}),
...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}),
});
setCreateIsLoading(false);
setIsCreated(true);
Expand Down Expand Up @@ -308,6 +324,27 @@ const CommunityCreate = (props: Props) => {
onChange={onDescriptionChange}
helperText={`${description.length}/280 characters`}
/>
{kfOrgs.length > 1 && (
<InputField label="Organization">
<div className={Classes.HTML_SELECT}>
<select
value={selectedKfOrgId ?? ''}
onChange={(e) =>
setSelectedKfOrgId(e.target.value || null)
}
>
{kfOrgs.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
{org.type === 'personal'
? ' (Personal)'
: ''}
</option>
))}
</select>
</div>
</InputField>
)}
{selectedTemplateId ? (
<Callout
intent="none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { usePageContext } from 'utils/hooks';
import DeleteCommunity from './DeleteCommunity';
import DiscussionsSection from './DiscussionsSection';
import { ExportCommunityDataButton } from './ExportCommunityDataButton';
import TransferOwnership from './TransferOwnership';

type PastExport = {
id: string;
Expand Down Expand Up @@ -98,6 +99,8 @@ const ExportAndDeleteSettings = (props: Props) => {

<ExportDataSection settingsData={props.settingsData} />

<TransferOwnership communityData={communityData} />

<DeleteCommunity communityData={communityData} />
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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<KFOrg[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOrgId, setSelectedOrgId] = 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 () => {
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 (
<SettingsSection title="Transfer Ownership">
<p className={Classes.TEXT_MUTED}>Loading organizations...</p>
</SettingsSection>
);
}

// 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 (
<SettingsSection title="Transfer Ownership">
<p>
Transfer this community to a different KF Account. The target account will become
the billing owner of this community.
</p>

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

{error && (
<Callout intent="danger" style={{ marginBottom: 10 }}>
{error}
</Callout>
)}

{success && (
<Callout intent="success" style={{ marginBottom: 10 }}>
{success}
</Callout>
)}

<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ flex: 1, maxWidth: 300 }}>
<div className={Classes.HTML_SELECT} style={{ width: '100%' }}>
<select
value={selectedOrgId ?? ''}
onChange={(e) => {
setSelectedOrgId(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)' : ''}
</option>
))}
</select>
</div>
</div>
<Button
intent="warning"
text="Transfer"
loading={isTransferring}
disabled={isCurrentOrg || !selectedOrgId}
onClick={handleTransfer}
/>
</div>
</SettingsSection>
);
};

export default TransferOwnership;
Loading
Loading