Skip to content

Commit

Permalink
Fix Civic functionality for DAO config UI (solana-labs#2002)
Browse files Browse the repository at this point in the history
  • Loading branch information
dankelleher authored Dec 22, 2023
1 parent ed57bbe commit 1c95e18
Show file tree
Hide file tree
Showing 12 changed files with 521 additions and 154 deletions.
23 changes: 23 additions & 0 deletions GatewayPlugin/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// A list of "passes" offered by Civic to verify or gate access to a DAO.
export const availablePasses = [
{
name: 'Uniqueness',
value: 'uniqobk8oGh4XBLMqM68K8M2zNu3CdYX7q5go7whQiv',
description: 'A biometric proof of personhood, preventing Sybil attacks while retaining privacy'
},
{
name: 'ID Verification',
value: 'bni1ewus6aMxTxBi5SAfzEmmXLf8KcVFRmTfproJuKw',
description: 'A KYC process for your DAO, allowing users to prove their identity by presenting a government-issued ID'
},
{
name: 'Bot Resistance',
value: 'ignREusXmGrscGNUesoU9mxfds9AiYTezUKex2PsZV6',
description: 'A simple CAPTCHA to prevent bots from spamming your DAO'
},
{
name: 'Other',
value: '',
description: 'Set up your own custom verification (contact Civic.com for options)'
},
] as const;
12 changes: 12 additions & 0 deletions hub/components/EditRealmConfig/CommunityStructure/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ interface Props
nftCollection?: PublicKey;
nftCollectionSize: number;
nftCollectionWeight: BN;
civicPassType: Config['civicPassType'];
}> {
currentConfigAccount: Config['configAccount'];
currentNftCollection?: PublicKey;
currentNftCollectionSize: number;
currentNftCollectionWeight: BN;
currentCivicPassType: Config['civicPassType'];
communityMint: Config['communityMint'];
className?: string;
}
Expand All @@ -43,6 +45,7 @@ export function CommunityStructure(props: Props) {
nftCollection: props.currentNftCollection,
nftCollectionSize: props.currentNftCollectionSize,
nftCollectionWeight: props.currentNftCollectionWeight,
civicPassType: props.currentCivicPassType,
};

const votingStructure = {
Expand All @@ -52,6 +55,7 @@ export function CommunityStructure(props: Props) {
nftCollection: props.nftCollection,
nftCollectionSize: props.nftCollectionSize,
nftCollectionWeight: props.nftCollectionWeight,
civicPassType: props.civicPassType,
};

const minTokensToManage = new BigNumber(
Expand Down Expand Up @@ -211,6 +215,7 @@ export function CommunityStructure(props: Props) {
nftCollection,
nftCollectionSize,
nftCollectionWeight,
civicPassType,
}) => {
const newConfig = produce(
{ ...props.configAccount },
Expand Down Expand Up @@ -246,6 +251,13 @@ export function CommunityStructure(props: Props) {
) {
props.onNftCollectionWeightChange?.(nftCollectionWeight);
}

if (
typeof civicPassType !== 'undefined' &&
!props.civicPassType?.equals(civicPassType)
) {
props.onCivicPassTypeChange?.(civicPassType);
}
}, 0);
}}
/>
Expand Down
9 changes: 9 additions & 0 deletions hub/components/EditRealmConfig/Form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ export function Form(props: Props) {
currentNftCollection={props.currentConfig.nftCollection}
currentNftCollectionSize={props.currentConfig.nftCollectionSize}
currentNftCollectionWeight={props.currentConfig.nftCollectionWeight}
currentCivicPassType={props.currentConfig.civicPassType}
nftCollection={props.config.nftCollection}
nftCollectionSize={props.config.nftCollectionSize}
nftCollectionWeight={props.config.nftCollectionWeight}
civicPassType={props.config.civicPassType}
onConfigChange={(config) => {
const newConfig = produce(props.config, (data) => {
data.config = config;
Expand Down Expand Up @@ -103,6 +105,13 @@ export function Form(props: Props) {
data.nftCollectionWeight = nftCollectionWeight;
});

props.onConfigChange?.(newConfig);
}}
onCivicPassTypeChange={(civicPassType) => {
const newConfig = produce(props.config, (data) => {
data.civicPassType = civicPassType;
});

props.onConfigChange?.(newConfig);
}}
/>
Expand Down
28 changes: 27 additions & 1 deletion hub/components/EditRealmConfig/UpdatesList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PublicKey } from '@solana/web3.js';
import { BigNumber } from 'bignumber.js';
import BN from 'bn.js';

import { availablePasses } from '../../../../GatewayPlugin/config';
import { Config } from '../fetchConfig';
import { getLabel } from '../TokenTypeSelector';
import {
Expand Down Expand Up @@ -45,6 +46,7 @@ export function buildUpdates(config: Config) {
nftCollection: config.nftCollection,
nftCollectionSize: config.nftCollectionSize,
nftCollectionWeight: config.nftCollectionWeight,
civicPassType: config.civicPassType,
};
}

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

const civicPassTypeLabel = (civicPassType: PublicKey | undefined): string => {
if (!civicPassType) return 'None';
const foundPass = availablePasses.find(
(pass) => pass.value === civicPassType?.toBase58(),
);

if (!foundPass) return 'Other (' + abbreviateAddress(civicPassType) + ')';
return foundPass.name;
};

function votingStructureText(
votingPluginDiff: [PublicKey | undefined, PublicKey | undefined],
maxVotingPluginDiff: [PublicKey | undefined, PublicKey | undefined],
Expand Down Expand Up @@ -156,7 +168,8 @@ export function UpdatesList(props: Props) {
'communityMaxVotingPlugin' in updates ||
'nftCollection' in updates ||
'nftCollectionSize' in updates ||
'nftCollectionWeight' in updates;
'nftCollectionWeight' in updates ||
'civicPassType' in updates;

const hasCouncilUpdates =
'councilTokenType' in updates ||
Expand Down Expand Up @@ -420,6 +433,19 @@ export function UpdatesList(props: Props) {
}
/>
)}
{'civicPassType' in updates && (
<SummaryItem
label="Civic Pass Type"
value={
<div className="flex items-baseline">
<div>{civicPassTypeLabel(updates.civicPassType[1])}</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{civicPassTypeLabel(updates.civicPassType[0])}
</div>
</div>
}
/>
)}
</div>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';

import { PublicKey } from '@solana/web3.js';
import React, { FC, useRef, useState } from 'react';

import { availablePasses } from '../../../../GatewayPlugin/config';
import Input from '@components/inputs/Input';
import cx from '@hub/lib/cx';

const itemStyles = cx(
'border',
'cursor-pointer',
'gap-x-4',
'grid-cols-[150px,1fr,20px]',
'grid',
'h-14',
'items-center',
'px-4',
'w-full',
'rounded-md',
'text-left',
'transition-colors',
'dark:bg-neutral-800',
'dark:border-neutral-700',
'dark:hover:bg-neutral-700',
);

const labelStyles = cx('font-700', 'dark:text-neutral-50', 'w-full');
const descriptionStyles = cx('dark:text-neutral-400 text-sm');
const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4');

// Infer the types from the available passes, giving type safety on the `other` and `default` pass types
type ArrayElement<
ArrayType extends readonly unknown[]
> = ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
type CivicPass = ArrayElement<typeof availablePasses>;

const isOther = (pass: CivicPass | undefined): boolean =>
pass?.name === 'Other';
const other = availablePasses.find(isOther) as CivicPass;

// if nothing is selected, Uniqueness is most likely what the user wants
const defaultPass = availablePasses.find(
(pass) => pass.name === 'Uniqueness',
) as CivicPass;

// If Other is selected, allow the user to enter a custom pass address here.
const ManualPassEntry: FC<{
manualPassType?: PublicKey;
onChange: (newManualPassType?: PublicKey) => void;
}> = ({ manualPassType, onChange }) => {
const [error, setError] = useState<string>();
const [inputValue, setInputValue] = useState<string>(
manualPassType?.toBase58() || '',
);

return (
<div className="relative">
<div className="absolute top-0 left-2 w-0 h-12 border-l dark:border-neutral-700" />
<div className="pt-10 pl-8">
<div className="relative">
<div
className={cx(
'absolute',
'border-b',
'border-l',
'top-2.5',
'h-5',
'mr-1',
'right-[100%]',
'rounded-bl',
'w-5',
'dark:border-neutral-700',
)}
/>
<Input
label="Pass Address"
value={inputValue}
type="text"
onChange={(evt) => {
const value = evt.target.value;
setInputValue(value);
try {
const pk = new PublicKey(value);
onChange(pk);
setError(undefined);
} catch {
setError('Invalid address');
}
}}
error={error}
/>
</div>
</div>
</div>
);
};

// A dropdown of all the available Civic Passes
const CivicPassDropdown: FC<{
className?: string;
previousSelected?: PublicKey;
onPassTypeChange(value: PublicKey | undefined): void;
}> = (props) => {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const [selectedPass, setSelectedPass] = useState<CivicPass | undefined>(
!!props.previousSelected
? availablePasses.find(
(pass) => pass.value === props.previousSelected?.toBase58(),
) ?? other
: defaultPass,
);

return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<div>
<DropdownMenu.Trigger
className={cx(
itemStyles,
props.className,
open && 'border dark:border-white/40',
)}
ref={trigger}
>
<div className={labelStyles}>
{selectedPass?.name || 'Select a Civic Pass'}
</div>
<div className={descriptionStyles}>
{selectedPass?.description || ''}
</div>
<ChevronDownIcon className={cx(iconStyles, open && '-rotate-180')} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="dark space-y-0.5 z-20 w-full"
sideOffset={2}
>
{availablePasses.map((config, i) => (
<DropdownMenu.Item
className={cx(
itemStyles,
'w-full',
'focus:outline-none',
'dark:focus:bg-neutral-700',
)}
key={i}
onClick={() => {
setSelectedPass(config);
props.onPassTypeChange(
config?.value ? new PublicKey(config.value) : undefined,
);
}}
>
<div className={labelStyles}>{config.name}</div>
<div className={descriptionStyles}>{config.description}</div>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
{isOther(selectedPass) && (
<ManualPassEntry
onChange={(manualPassType) => {
setSelectedPass(other);
props.onPassTypeChange(manualPassType);
}}
manualPassType={
props.previousSelected && selectedPass !== other
? props.previousSelected
: undefined
}
/>
)}
</DropdownMenu.Root>
);
};

interface Props {
className?: string;
currentPassType?: PublicKey;
onPassTypeChange(value: PublicKey | undefined): void;
}

export function CivicConfigurator(props: Props) {
return (
<div className={props.className}>
<div className="relative">
<div className="absolute top-0 left-2 w-0 h-24 border-l dark:border-neutral-700" />
<div className="pt-10 pl-8">
<div className="text-white font-bold mb-3">
What type of verification?
</div>
<div className="relative">
<div
className={cx(
'absolute',
'border-b',
'border-l',
'top-2.5',
'h-5',
'mr-1',
'right-[100%]',
'rounded-bl',
'w-5',
'dark:border-neutral-700',
)}
/>
<CivicPassDropdown
previousSelected={props.currentPassType}
onPassTypeChange={props.onPassTypeChange}
/>
</div>
</div>
</div>
</div>
);
}
Loading

0 comments on commit 1c95e18

Please sign in to comment.