Skip to content

Commit 44d2769

Browse files
authored
feat: Switch to using kf-auth (#3613)
1 parent eb5a3f7 commit 44d2769

30 files changed

Lines changed: 2398 additions & 139 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ yarn-error.log*
4040
.env.*
4141
!.env.enc
4242
!.env.dev.enc
43+
!.env.local.enc
4344
!infra/.env.test
4445

4546

@@ -73,4 +74,4 @@ infra/pgdata/
7374
tmp/
7475

7576
planning/
76-
.claude
77+
.claude

client/containers/CommunityCreate/CommunityCreate.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,19 @@ const CommunityCreatedView = ({ subdomain, hubName }: { subdomain: string; hubNa
8282
);
8383
};
8484

85+
type KFOrg = {
86+
id: string;
87+
name: string;
88+
slug: string;
89+
type: 'personal' | 'shared';
90+
role: string;
91+
};
92+
8593
type Props = {
8694
hubData?: Hub | null;
8795
templates?: CommunityTemplate[];
8896
hubCommunities?: { id: string; title: string; subdomain: string; avatar?: string | null }[];
97+
kfOrgs?: KFOrg[];
8998
};
9099

91100
const HubBrandedHeader = ({ hub }: { hub: Hub }) => {
@@ -109,7 +118,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => {
109118
};
110119

111120
const CommunityCreate = (props: Props) => {
112-
const { hubData, templates = [], hubCommunities = [] } = props;
121+
const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props;
113122
const { loginData, locationData } = usePageContext();
114123
const altchaRef = useRef<import('components').AltchaRef>(null);
115124
const hubSlug = hubData?.slug || locationData?.query?.hub || null;
@@ -126,6 +135,12 @@ const CommunityCreate = (props: Props) => {
126135
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
127136
const [cloneCommunityId, setCloneCommunityId] = useState<string | null>(null);
128137

138+
// KF org picker: default to personal org, or first available
139+
const personalOrg = kfOrgs.find((o) => o.type === 'personal');
140+
const [selectedKfOrgId, setSelectedKfOrgId] = useState<string | null>(
141+
personalOrg?.id ?? kfOrgs[0]?.id ?? null,
142+
);
143+
129144
const hasHub = !!hubData;
130145
const hubAccentDark = hubData?.accentColorDark || '#2D2E2F';
131146

@@ -163,6 +178,7 @@ const CommunityCreate = (props: Props) => {
163178
...(selectedTemplateId === CLONE_MARKER && cloneCommunityId
164179
? { cloneCommunityId }
165180
: {}),
181+
...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}),
166182
});
167183
setCreateIsLoading(false);
168184
setIsCreated(true);
@@ -308,6 +324,27 @@ const CommunityCreate = (props: Props) => {
308324
onChange={onDescriptionChange}
309325
helperText={`${description.length}/280 characters`}
310326
/>
327+
{kfOrgs.length > 1 && (
328+
<InputField label="Organization">
329+
<div className={Classes.HTML_SELECT}>
330+
<select
331+
value={selectedKfOrgId ?? ''}
332+
onChange={(e) =>
333+
setSelectedKfOrgId(e.target.value || null)
334+
}
335+
>
336+
{kfOrgs.map((org) => (
337+
<option key={org.id} value={org.id}>
338+
{org.name}
339+
{org.type === 'personal'
340+
? ' (Personal)'
341+
: ''}
342+
</option>
343+
))}
344+
</select>
345+
</div>
346+
</InputField>
347+
)}
311348
{selectedTemplateId ? (
312349
<Callout
313350
intent="none"

client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import CommunityAuthTokens from './CommunityAuthTokens';
1212
import DeleteCommunity from './DeleteCommunity';
1313
import DiscussionsSection from './DiscussionsSection';
1414
import { ExportCommunityDataButton } from './ExportCommunityDataButton';
15+
import TransferOwnership from './TransferOwnership';
1516

1617
type PastExport = {
1718
id: string;
@@ -101,6 +102,8 @@ const ExportAndDeleteSettings = (props: Props) => {
101102

102103
{communityData && <CommunityAuthTokens communityData={communityData} />}
103104

105+
<TransferOwnership communityData={communityData} />
106+
104107
<DeleteCommunity communityData={communityData} />
105108
</>
106109
);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
3+
import { Button, Callout, Classes } from '@blueprintjs/core';
4+
5+
import { apiFetch } from 'client/utils/apiFetch';
6+
import { SettingsSection } from 'components';
7+
8+
type KFOrg = {
9+
id: string;
10+
name: string;
11+
slug: string;
12+
type: 'personal' | 'shared';
13+
role: string;
14+
};
15+
16+
type Props = {
17+
communityData: {
18+
id: string;
19+
title: string;
20+
kfOrgId: string | null;
21+
};
22+
};
23+
24+
const TransferOwnership = (props: Props) => {
25+
const { communityData } = props;
26+
const [orgs, setOrgs] = useState<KFOrg[]>([]);
27+
const [loading, setLoading] = useState(true);
28+
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
29+
const [isTransferring, setIsTransferring] = useState(false);
30+
const [error, setError] = useState<string | null>(null);
31+
const [success, setSuccess] = useState<string | null>(null);
32+
33+
const loadOrgs = useCallback(async () => {
34+
try {
35+
const data = await apiFetch.get('/api/kf/my-orgs');
36+
const fetchedOrgs: KFOrg[] = data.orgs ?? [];
37+
setOrgs(fetchedOrgs);
38+
// Default to current org if set, otherwise first org
39+
if (communityData.kfOrgId && fetchedOrgs.some((o) => o.id === communityData.kfOrgId)) {
40+
setSelectedOrgId(communityData.kfOrgId);
41+
} else if (fetchedOrgs.length > 0) {
42+
setSelectedOrgId(fetchedOrgs[0].id);
43+
}
44+
} catch {
45+
setError('Failed to load organizations');
46+
} finally {
47+
setLoading(false);
48+
}
49+
}, [communityData.kfOrgId]);
50+
51+
useEffect(() => {
52+
loadOrgs();
53+
}, [loadOrgs]);
54+
55+
const selectedOrg = orgs.find((o) => o.id === selectedOrgId);
56+
const isCurrentOrg = selectedOrgId === communityData.kfOrgId;
57+
58+
const handleTransfer = async () => {
59+
if (!selectedOrgId || isCurrentOrg) return;
60+
setIsTransferring(true);
61+
setError(null);
62+
setSuccess(null);
63+
try {
64+
await apiFetch.post('/api/kf/transfer-community', {
65+
communityId: communityData.id,
66+
kfOrgId: selectedOrgId,
67+
});
68+
setSuccess(
69+
`Community transferred to ${selectedOrg?.name ?? 'the selected organization'}.`,
70+
);
71+
// Update the local state so the button disables
72+
communityData.kfOrgId = selectedOrgId;
73+
} catch (err: any) {
74+
setError(err?.error || err?.message || 'Failed to transfer community');
75+
} finally {
76+
setIsTransferring(false);
77+
}
78+
};
79+
80+
if (loading) {
81+
return (
82+
<SettingsSection title="Transfer Ownership">
83+
<p className={Classes.TEXT_MUTED}>Loading organizations...</p>
84+
</SettingsSection>
85+
);
86+
}
87+
88+
// Need at least 2 orgs to have somewhere to transfer to
89+
if (orgs.length < 2) {
90+
return null;
91+
}
92+
93+
const currentOrg = orgs.find((o) => o.id === communityData.kfOrgId);
94+
95+
return (
96+
<SettingsSection title="Transfer Ownership">
97+
<p>
98+
Transfer this community to a different KF Account. The target account will become
99+
the billing owner of this community.
100+
</p>
101+
102+
{currentOrg && (
103+
<p>
104+
Currently owned by: <strong>{currentOrg.name}</strong>
105+
{currentOrg.type === 'personal' ? ' (Personal)' : ''}
106+
</p>
107+
)}
108+
109+
{error && (
110+
<Callout intent="danger" style={{ marginBottom: 10 }}>
111+
{error}
112+
</Callout>
113+
)}
114+
115+
{success && (
116+
<Callout intent="success" style={{ marginBottom: 10 }}>
117+
{success}
118+
</Callout>
119+
)}
120+
121+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
122+
<div style={{ flex: 1, maxWidth: 300 }}>
123+
<div className={Classes.HTML_SELECT} style={{ width: '100%' }}>
124+
<select
125+
value={selectedOrgId ?? ''}
126+
onChange={(e) => {
127+
setSelectedOrgId(e.target.value || null);
128+
setSuccess(null);
129+
}}
130+
disabled={isTransferring}
131+
>
132+
{orgs.map((org) => (
133+
<option key={org.id} value={org.id}>
134+
{org.name}
135+
{org.type === 'personal' ? ' (Personal)' : ''}
136+
{org.id === communityData.kfOrgId ? ' (current)' : ''}
137+
</option>
138+
))}
139+
</select>
140+
</div>
141+
</div>
142+
<Button
143+
intent="warning"
144+
text="Transfer"
145+
loading={isTransferring}
146+
disabled={isCurrentOrg || !selectedOrgId}
147+
onClick={handleTransfer}
148+
/>
149+
</div>
150+
</SettingsSection>
151+
);
152+
};
153+
154+
export default TransferOwnership;

0 commit comments

Comments
 (0)