Skip to content

Commit b8c3c62

Browse files
authored
feat: allow users to mint authentication tokens from the ui, and communities to revert them (#3614)
1 parent 4d6c72e commit b8c3c62

16 files changed

Lines changed: 935 additions & 16 deletions

File tree

client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SettingsSection } from 'components';
88
import { getDashUrl } from 'utils/dashboard';
99
import { usePageContext } from 'utils/hooks';
1010

11+
import CommunityAuthTokens from './CommunityAuthTokens';
1112
import DeleteCommunity from './DeleteCommunity';
1213
import DiscussionsSection from './DiscussionsSection';
1314
import { ExportCommunityDataButton } from './ExportCommunityDataButton';
@@ -98,6 +99,8 @@ const ExportAndDeleteSettings = (props: Props) => {
9899

99100
<ExportDataSection settingsData={props.settingsData} />
100101

102+
{communityData && <CommunityAuthTokens communityData={communityData} />}
103+
101104
<DeleteCommunity communityData={communityData} />
102105
</>
103106
);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
3+
import { Button, Callout, HTMLTable, Tag } from '@blueprintjs/core';
4+
5+
import { apiFetch } from 'client/utils/apiFetch';
6+
import { ConfirmDialog, SettingsSection } from 'components';
7+
8+
type CommunityAuthToken = {
9+
id: string;
10+
userId: string;
11+
communityId: string;
12+
expiresAt: string | null;
13+
createdAt: string;
14+
user?: {
15+
id: string;
16+
fullName?: string | null;
17+
slug?: string | null;
18+
avatar?: string | null;
19+
initials?: string | null;
20+
} | null;
21+
};
22+
23+
type Props = {
24+
communityData: {
25+
id: string;
26+
title: string;
27+
};
28+
};
29+
30+
const formatDate = (iso: string | null | undefined) => {
31+
if (!iso) {
32+
return null;
33+
}
34+
const date = new Date(iso);
35+
if (Number.isNaN(date.getTime())) {
36+
return null;
37+
}
38+
return date.toLocaleDateString();
39+
};
40+
41+
const errorMessage = (e: unknown, fallback: string) => {
42+
if (e instanceof Error) {
43+
return e.message;
44+
}
45+
if (typeof e === 'object' && e !== null && 'message' in e) {
46+
return (e as { message: string }).message;
47+
}
48+
return fallback;
49+
};
50+
51+
const CommunityAuthTokens = ({ communityData }: Props) => {
52+
const [tokens, setTokens] = useState<CommunityAuthToken[]>([]);
53+
const [isLoading, setIsLoading] = useState(true);
54+
const [loadError, setLoadError] = useState<string | null>(null);
55+
56+
useEffect(() => {
57+
let cancelled = false;
58+
setIsLoading(true);
59+
apiFetch
60+
.get(`/api/authTokens/community/${communityData.id}`)
61+
.then((result: CommunityAuthToken[]) => {
62+
if (!cancelled) {
63+
setTokens(result);
64+
setLoadError(null);
65+
}
66+
})
67+
.catch((e) => {
68+
if (!cancelled) {
69+
setLoadError(errorMessage(e, 'Failed to load community auth tokens.'));
70+
}
71+
})
72+
.finally(() => {
73+
if (!cancelled) {
74+
setIsLoading(false);
75+
}
76+
});
77+
return () => {
78+
cancelled = true;
79+
};
80+
}, [communityData.id]);
81+
82+
const handleRevoke = useCallback(
83+
async (tokenId: string) => {
84+
await apiFetch.delete(`/api/authTokens/community/${communityData.id}/${tokenId}`);
85+
setTokens((prev) => prev.filter((t) => t.id !== tokenId));
86+
},
87+
[communityData.id],
88+
);
89+
90+
return (
91+
<SettingsSection title="Auth tokens">
92+
<p>
93+
Auth tokens grant the token’s owner programmatic access to this community with their
94+
full admin privileges. Demoting a user automatically invalidates their tokens;
95+
revoking a token here cuts off a single token without changing the user’s role.
96+
</p>
97+
98+
{loadError && (
99+
<Callout intent="danger" style={{ marginBottom: 8 }}>
100+
{loadError}
101+
</Callout>
102+
)}
103+
104+
{!isLoading && tokens.length === 0 && !loadError && (
105+
<p style={{ opacity: 0.7 }}>No auth tokens have been minted for this community.</p>
106+
)}
107+
108+
{tokens.length > 0 && (
109+
<HTMLTable condensed striped style={{ width: '100%' }}>
110+
<thead>
111+
<tr>
112+
<th>Owner</th>
113+
<th>Created</th>
114+
<th>Expires</th>
115+
<th />
116+
</tr>
117+
</thead>
118+
<tbody>
119+
{tokens.map((t) => {
120+
const expires = formatDate(t.expiresAt);
121+
const isExpired = !!(
122+
t.expiresAt && new Date(t.expiresAt).getTime() < Date.now()
123+
);
124+
const ownerName = t.user?.fullName || t.user?.slug || t.userId;
125+
return (
126+
<tr key={t.id}>
127+
<td>
128+
{t.user?.slug ? (
129+
<a href={`/user/${t.user.slug}`}>{ownerName}</a>
130+
) : (
131+
ownerName
132+
)}
133+
</td>
134+
<td>{formatDate(t.createdAt) ?? '—'}</td>
135+
<td>
136+
{expires ? (
137+
isExpired ? (
138+
<Tag minimal intent="warning">
139+
Expired
140+
</Tag>
141+
) : (
142+
expires
143+
)
144+
) : (
145+
'Never'
146+
)}
147+
</td>
148+
<td style={{ textAlign: 'right' }}>
149+
<ConfirmDialog
150+
title="Revoke auth token"
151+
text={
152+
<p>
153+
Revoking this token will immediately invalidate
154+
it for {communityData.title}. The owner can mint
155+
a new one if they are still an admin.
156+
</p>
157+
}
158+
confirmLabel="Revoke"
159+
onConfirm={() => handleRevoke(t.id)}
160+
>
161+
{({ open }) => (
162+
<Button
163+
minimal
164+
small
165+
intent="danger"
166+
onClick={open}
167+
>
168+
Revoke
169+
</Button>
170+
)}
171+
</ConfirmDialog>
172+
</td>
173+
</tr>
174+
);
175+
})}
176+
</tbody>
177+
</HTMLTable>
178+
)}
179+
</SettingsSection>
180+
);
181+
};
182+
183+
export default CommunityAuthTokens;

0 commit comments

Comments
 (0)