Skip to content

Commit 1c95e18

Browse files
authored
Fix Civic functionality for DAO config UI (solana-labs#2002)
1 parent ed57bbe commit 1c95e18

File tree

12 files changed

+521
-154
lines changed

12 files changed

+521
-154
lines changed

GatewayPlugin/config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// A list of "passes" offered by Civic to verify or gate access to a DAO.
2+
export const availablePasses = [
3+
{
4+
name: 'Uniqueness',
5+
value: 'uniqobk8oGh4XBLMqM68K8M2zNu3CdYX7q5go7whQiv',
6+
description: 'A biometric proof of personhood, preventing Sybil attacks while retaining privacy'
7+
},
8+
{
9+
name: 'ID Verification',
10+
value: 'bni1ewus6aMxTxBi5SAfzEmmXLf8KcVFRmTfproJuKw',
11+
description: 'A KYC process for your DAO, allowing users to prove their identity by presenting a government-issued ID'
12+
},
13+
{
14+
name: 'Bot Resistance',
15+
value: 'ignREusXmGrscGNUesoU9mxfds9AiYTezUKex2PsZV6',
16+
description: 'A simple CAPTCHA to prevent bots from spamming your DAO'
17+
},
18+
{
19+
name: 'Other',
20+
value: '',
21+
description: 'Set up your own custom verification (contact Civic.com for options)'
22+
},
23+
] as const;

hub/components/EditRealmConfig/CommunityStructure/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ interface Props
2525
nftCollection?: PublicKey;
2626
nftCollectionSize: number;
2727
nftCollectionWeight: BN;
28+
civicPassType: Config['civicPassType'];
2829
}> {
2930
currentConfigAccount: Config['configAccount'];
3031
currentNftCollection?: PublicKey;
3132
currentNftCollectionSize: number;
3233
currentNftCollectionWeight: BN;
34+
currentCivicPassType: Config['civicPassType'];
3335
communityMint: Config['communityMint'];
3436
className?: string;
3537
}
@@ -43,6 +45,7 @@ export function CommunityStructure(props: Props) {
4345
nftCollection: props.currentNftCollection,
4446
nftCollectionSize: props.currentNftCollectionSize,
4547
nftCollectionWeight: props.currentNftCollectionWeight,
48+
civicPassType: props.currentCivicPassType,
4649
};
4750

4851
const votingStructure = {
@@ -52,6 +55,7 @@ export function CommunityStructure(props: Props) {
5255
nftCollection: props.nftCollection,
5356
nftCollectionSize: props.nftCollectionSize,
5457
nftCollectionWeight: props.nftCollectionWeight,
58+
civicPassType: props.civicPassType,
5559
};
5660

5761
const minTokensToManage = new BigNumber(
@@ -211,6 +215,7 @@ export function CommunityStructure(props: Props) {
211215
nftCollection,
212216
nftCollectionSize,
213217
nftCollectionWeight,
218+
civicPassType,
214219
}) => {
215220
const newConfig = produce(
216221
{ ...props.configAccount },
@@ -246,6 +251,13 @@ export function CommunityStructure(props: Props) {
246251
) {
247252
props.onNftCollectionWeightChange?.(nftCollectionWeight);
248253
}
254+
255+
if (
256+
typeof civicPassType !== 'undefined' &&
257+
!props.civicPassType?.equals(civicPassType)
258+
) {
259+
props.onCivicPassTypeChange?.(civicPassType);
260+
}
249261
}, 0);
250262
}}
251263
/>

hub/components/EditRealmConfig/Form/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ export function Form(props: Props) {
6767
currentNftCollection={props.currentConfig.nftCollection}
6868
currentNftCollectionSize={props.currentConfig.nftCollectionSize}
6969
currentNftCollectionWeight={props.currentConfig.nftCollectionWeight}
70+
currentCivicPassType={props.currentConfig.civicPassType}
7071
nftCollection={props.config.nftCollection}
7172
nftCollectionSize={props.config.nftCollectionSize}
7273
nftCollectionWeight={props.config.nftCollectionWeight}
74+
civicPassType={props.config.civicPassType}
7375
onConfigChange={(config) => {
7476
const newConfig = produce(props.config, (data) => {
7577
data.config = config;
@@ -103,6 +105,13 @@ export function Form(props: Props) {
103105
data.nftCollectionWeight = nftCollectionWeight;
104106
});
105107

108+
props.onConfigChange?.(newConfig);
109+
}}
110+
onCivicPassTypeChange={(civicPassType) => {
111+
const newConfig = produce(props.config, (data) => {
112+
data.civicPassType = civicPassType;
113+
});
114+
106115
props.onConfigChange?.(newConfig);
107116
}}
108117
/>

hub/components/EditRealmConfig/UpdatesList/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { PublicKey } from '@solana/web3.js';
1010
import { BigNumber } from 'bignumber.js';
1111
import BN from 'bn.js';
1212

13+
import { availablePasses } from '../../../../GatewayPlugin/config';
1314
import { Config } from '../fetchConfig';
1415
import { getLabel } from '../TokenTypeSelector';
1516
import {
@@ -45,6 +46,7 @@ export function buildUpdates(config: Config) {
4546
nftCollection: config.nftCollection,
4647
nftCollectionSize: config.nftCollectionSize,
4748
nftCollectionWeight: config.nftCollectionWeight,
49+
civicPassType: config.civicPassType,
4850
};
4951
}
5052

@@ -81,6 +83,16 @@ export function diff<T extends { [key: string]: unknown }>(
8183
return diffs;
8284
}
8385

86+
const civicPassTypeLabel = (civicPassType: PublicKey | undefined): string => {
87+
if (!civicPassType) return 'None';
88+
const foundPass = availablePasses.find(
89+
(pass) => pass.value === civicPassType?.toBase58(),
90+
);
91+
92+
if (!foundPass) return 'Other (' + abbreviateAddress(civicPassType) + ')';
93+
return foundPass.name;
94+
};
95+
8496
function votingStructureText(
8597
votingPluginDiff: [PublicKey | undefined, PublicKey | undefined],
8698
maxVotingPluginDiff: [PublicKey | undefined, PublicKey | undefined],
@@ -156,7 +168,8 @@ export function UpdatesList(props: Props) {
156168
'communityMaxVotingPlugin' in updates ||
157169
'nftCollection' in updates ||
158170
'nftCollectionSize' in updates ||
159-
'nftCollectionWeight' in updates;
171+
'nftCollectionWeight' in updates ||
172+
'civicPassType' in updates;
160173

161174
const hasCouncilUpdates =
162175
'councilTokenType' in updates ||
@@ -420,6 +433,19 @@ export function UpdatesList(props: Props) {
420433
}
421434
/>
422435
)}
436+
{'civicPassType' in updates && (
437+
<SummaryItem
438+
label="Civic Pass Type"
439+
value={
440+
<div className="flex items-baseline">
441+
<div>{civicPassTypeLabel(updates.civicPassType[1])}</div>
442+
<div className="ml-3 text-base text-neutral-500 line-through">
443+
{civicPassTypeLabel(updates.civicPassType[0])}
444+
</div>
445+
</div>
446+
}
447+
/>
448+
)}
423449
</div>
424450
</div>
425451
)}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
2+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
3+
4+
import { PublicKey } from '@solana/web3.js';
5+
import React, { FC, useRef, useState } from 'react';
6+
7+
import { availablePasses } from '../../../../GatewayPlugin/config';
8+
import Input from '@components/inputs/Input';
9+
import cx from '@hub/lib/cx';
10+
11+
const itemStyles = cx(
12+
'border',
13+
'cursor-pointer',
14+
'gap-x-4',
15+
'grid-cols-[150px,1fr,20px]',
16+
'grid',
17+
'h-14',
18+
'items-center',
19+
'px-4',
20+
'w-full',
21+
'rounded-md',
22+
'text-left',
23+
'transition-colors',
24+
'dark:bg-neutral-800',
25+
'dark:border-neutral-700',
26+
'dark:hover:bg-neutral-700',
27+
);
28+
29+
const labelStyles = cx('font-700', 'dark:text-neutral-50', 'w-full');
30+
const descriptionStyles = cx('dark:text-neutral-400 text-sm');
31+
const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4');
32+
33+
// Infer the types from the available passes, giving type safety on the `other` and `default` pass types
34+
type ArrayElement<
35+
ArrayType extends readonly unknown[]
36+
> = ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
37+
type CivicPass = ArrayElement<typeof availablePasses>;
38+
39+
const isOther = (pass: CivicPass | undefined): boolean =>
40+
pass?.name === 'Other';
41+
const other = availablePasses.find(isOther) as CivicPass;
42+
43+
// if nothing is selected, Uniqueness is most likely what the user wants
44+
const defaultPass = availablePasses.find(
45+
(pass) => pass.name === 'Uniqueness',
46+
) as CivicPass;
47+
48+
// If Other is selected, allow the user to enter a custom pass address here.
49+
const ManualPassEntry: FC<{
50+
manualPassType?: PublicKey;
51+
onChange: (newManualPassType?: PublicKey) => void;
52+
}> = ({ manualPassType, onChange }) => {
53+
const [error, setError] = useState<string>();
54+
const [inputValue, setInputValue] = useState<string>(
55+
manualPassType?.toBase58() || '',
56+
);
57+
58+
return (
59+
<div className="relative">
60+
<div className="absolute top-0 left-2 w-0 h-12 border-l dark:border-neutral-700" />
61+
<div className="pt-10 pl-8">
62+
<div className="relative">
63+
<div
64+
className={cx(
65+
'absolute',
66+
'border-b',
67+
'border-l',
68+
'top-2.5',
69+
'h-5',
70+
'mr-1',
71+
'right-[100%]',
72+
'rounded-bl',
73+
'w-5',
74+
'dark:border-neutral-700',
75+
)}
76+
/>
77+
<Input
78+
label="Pass Address"
79+
value={inputValue}
80+
type="text"
81+
onChange={(evt) => {
82+
const value = evt.target.value;
83+
setInputValue(value);
84+
try {
85+
const pk = new PublicKey(value);
86+
onChange(pk);
87+
setError(undefined);
88+
} catch {
89+
setError('Invalid address');
90+
}
91+
}}
92+
error={error}
93+
/>
94+
</div>
95+
</div>
96+
</div>
97+
);
98+
};
99+
100+
// A dropdown of all the available Civic Passes
101+
const CivicPassDropdown: FC<{
102+
className?: string;
103+
previousSelected?: PublicKey;
104+
onPassTypeChange(value: PublicKey | undefined): void;
105+
}> = (props) => {
106+
const [open, setOpen] = useState(false);
107+
const trigger = useRef<HTMLButtonElement>(null);
108+
const [selectedPass, setSelectedPass] = useState<CivicPass | undefined>(
109+
!!props.previousSelected
110+
? availablePasses.find(
111+
(pass) => pass.value === props.previousSelected?.toBase58(),
112+
) ?? other
113+
: defaultPass,
114+
);
115+
116+
return (
117+
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
118+
<div>
119+
<DropdownMenu.Trigger
120+
className={cx(
121+
itemStyles,
122+
props.className,
123+
open && 'border dark:border-white/40',
124+
)}
125+
ref={trigger}
126+
>
127+
<div className={labelStyles}>
128+
{selectedPass?.name || 'Select a Civic Pass'}
129+
</div>
130+
<div className={descriptionStyles}>
131+
{selectedPass?.description || ''}
132+
</div>
133+
<ChevronDownIcon className={cx(iconStyles, open && '-rotate-180')} />
134+
</DropdownMenu.Trigger>
135+
<DropdownMenu.Portal>
136+
<DropdownMenu.Content
137+
className="dark space-y-0.5 z-20 w-full"
138+
sideOffset={2}
139+
>
140+
{availablePasses.map((config, i) => (
141+
<DropdownMenu.Item
142+
className={cx(
143+
itemStyles,
144+
'w-full',
145+
'focus:outline-none',
146+
'dark:focus:bg-neutral-700',
147+
)}
148+
key={i}
149+
onClick={() => {
150+
setSelectedPass(config);
151+
props.onPassTypeChange(
152+
config?.value ? new PublicKey(config.value) : undefined,
153+
);
154+
}}
155+
>
156+
<div className={labelStyles}>{config.name}</div>
157+
<div className={descriptionStyles}>{config.description}</div>
158+
</DropdownMenu.Item>
159+
))}
160+
</DropdownMenu.Content>
161+
</DropdownMenu.Portal>
162+
</div>
163+
{isOther(selectedPass) && (
164+
<ManualPassEntry
165+
onChange={(manualPassType) => {
166+
setSelectedPass(other);
167+
props.onPassTypeChange(manualPassType);
168+
}}
169+
manualPassType={
170+
props.previousSelected && selectedPass !== other
171+
? props.previousSelected
172+
: undefined
173+
}
174+
/>
175+
)}
176+
</DropdownMenu.Root>
177+
);
178+
};
179+
180+
interface Props {
181+
className?: string;
182+
currentPassType?: PublicKey;
183+
onPassTypeChange(value: PublicKey | undefined): void;
184+
}
185+
186+
export function CivicConfigurator(props: Props) {
187+
return (
188+
<div className={props.className}>
189+
<div className="relative">
190+
<div className="absolute top-0 left-2 w-0 h-24 border-l dark:border-neutral-700" />
191+
<div className="pt-10 pl-8">
192+
<div className="text-white font-bold mb-3">
193+
What type of verification?
194+
</div>
195+
<div className="relative">
196+
<div
197+
className={cx(
198+
'absolute',
199+
'border-b',
200+
'border-l',
201+
'top-2.5',
202+
'h-5',
203+
'mr-1',
204+
'right-[100%]',
205+
'rounded-bl',
206+
'w-5',
207+
'dark:border-neutral-700',
208+
)}
209+
/>
210+
<CivicPassDropdown
211+
previousSelected={props.currentPassType}
212+
onPassTypeChange={props.onPassTypeChange}
213+
/>
214+
</div>
215+
</div>
216+
</div>
217+
</div>
218+
);
219+
}

0 commit comments

Comments
 (0)