diff --git a/src/components/Icon/nucleus.ts b/src/components/Icon/nucleus.ts
index 48a0320..e4ed619 100644
--- a/src/components/Icon/nucleus.ts
+++ b/src/components/Icon/nucleus.ts
@@ -29,6 +29,7 @@ import shieldCheck from "@worldcoin/nucleus/icons/shield-check-regular.svg";
import shieldHalf from "@worldcoin/nucleus/icons/shield-half-solid.svg";
import smartphone from "@worldcoin/nucleus/icons/smartphone-regular.svg";
import text from "@worldcoin/nucleus/icons/text-solid.svg";
+import trash from "@worldcoin/nucleus/icons/trash-solid.svg";
import warningTriangle from "@worldcoin/nucleus/icons/warning-triangle-regular.svg";
import worldcoin from "@worldcoin/nucleus/icons/worldcoin-regular.svg";
import xmarkCircle from "@worldcoin/nucleus/icons/xmark-circle-regular.svg";
@@ -68,6 +69,7 @@ export const NUCLEUS_ICONS = {
"shield-half": asset(shieldHalf),
smartphone: asset(smartphone),
text: asset(text),
+ trash: asset(trash),
"warning-triangle": asset(warningTriangle),
worldcoin: asset(worldcoin),
xmark: asset(xmark),
diff --git a/src/components/Row.tsx b/src/components/Row.tsx
index e9c5470..7d01f79 100644
--- a/src/components/Row.tsx
+++ b/src/components/Row.tsx
@@ -13,53 +13,59 @@ export function Row(props: {
leading?: ReactNode;
detail?: string;
trailing?: ReactNode;
+ /** Controls rendered beside the row, outside its button (e.g. a remove action). */
+ actions?: ReactNode;
destructive?: boolean;
divider?: boolean;
disabled?: boolean;
onClick?: () => void;
}) {
return (
-
+ {props.actions}
+
);
}
diff --git a/src/components/SelectID/IDRow.tsx b/src/components/SelectID/IDRow.tsx
index f553cbf..1f2db54 100644
--- a/src/components/SelectID/IDRow.tsx
+++ b/src/components/SelectID/IDRow.tsx
@@ -1,10 +1,12 @@
import { Icon } from "@/components/Icon";
import { Row } from "@/components/Row";
+import useIdentity from "@/hooks/useIdentity";
import { credentialsForIdentity } from "@/lib/credentials";
import type { Identity } from "@/types";
import Image from "next/image";
import { useRouter } from "next/router";
import { useMemo } from "react";
+import toast from "react-hot-toast";
export default function IDRow(props: {
identity: Identity;
@@ -12,6 +14,8 @@ export default function IDRow(props: {
divider?: boolean;
}) {
const router = useRouter();
+ const { removeIdentity } = useIdentity();
+ const name = props.identity.meta.name;
const detail = useMemo(() => {
const credentials = credentialsForIdentity(props.identity);
@@ -19,10 +23,15 @@ export default function IDRow(props: {
return credentials.map((credential) => credential.title).join(" ยท ");
}, [props.identity]);
+ const handleRemove = () => {
+ removeIdentity(props.identity.id);
+ toast(`Removed ${name}`);
+ };
+
return (
}
divider={props.divider}
@@ -35,6 +44,20 @@ export default function IDRow(props: {
/>
) : undefined
}
+ actions={
+ // Revealed on hover or keyboard focus; always visible where there is no hover.
+
+
+
+ }
onClick={() => void router.push(`/id/${props.identity.id}`)}
/>
diff --git a/src/hooks/useIdentity.ts b/src/hooks/useIdentity.ts
index 37d6daf..f640702 100644
--- a/src/hooks/useIdentity.ts
+++ b/src/hooks/useIdentity.ts
@@ -15,6 +15,7 @@ const getStore = (store: IdentityStore) => ({
insertIdentity: store.insertIdentity,
replaceIdentity: store.replaceIdentity,
reset: store.reset,
+ removeIdentity: store.removeIdentity,
});
const useIdentity = () => {
@@ -25,6 +26,7 @@ const useIdentity = () => {
insertIdentity,
replaceIdentity,
reset,
+ removeIdentity,
} = useIdentityStore(getStore);
const generateIdentityProofsIfNeeded = useCallback(
@@ -190,6 +192,7 @@ const useIdentity = () => {
? null
: activeIdentityID,
generateNextIdentity,
+ removeIdentity,
identities,
activeIdentity: activeIdentity,
resetIdentityStore,
diff --git a/src/pages/select-id/index.tsx b/src/pages/select-id/index.tsx
index 9c9dc30..00dfc6f 100644
--- a/src/pages/select-id/index.tsx
+++ b/src/pages/select-id/index.tsx
@@ -1,18 +1,30 @@
import { NavBar, NavBarButton } from "@/components/NavBar";
import useIdentity from "@/hooks/useIdentity";
import dynamic from "next/dynamic";
+import { useRouter } from "next/router";
const DynamicList = dynamic(() => import("@/components/SelectID/IDsList"), {
ssr: false,
});
export default function Select() {
- const { generateNextIdentity } = useIdentity();
+ const router = useRouter();
+ const { activeIdentityID, generateNextIdentity } = useIdentity();
return (
void router.push(`/id/${activeIdentityID}`)}
+ />
+ )
+ }
trailing={
void;
insertIdentity: (identity: Identity) => void;
replaceIdentity: (identity: Identity) => void;
+ removeIdentity: (id: string) => void;
reset: () => void;
};
@@ -46,6 +47,19 @@ export const useIdentityStore = create()(
return i;
}),
})),
+ // Removing the active identity hands the active slot to the most recent
+ // remaining one, so the home screen never points at a missing identity.
+ removeIdentity: (id) =>
+ set((state) => {
+ const identities = state.identities.filter((i) => i.id !== id);
+ return {
+ identities,
+ activeIdentityID:
+ state.activeIdentityID === id
+ ? identities[0]?.id ?? null
+ : state.activeIdentityID,
+ };
+ }),
reset: () =>
set(() => ({
identities: [],
diff --git a/tests/identity-store.test.ts b/tests/identity-store.test.ts
index b5e36b0..dfa2ff2 100644
--- a/tests/identity-store.test.ts
+++ b/tests/identity-store.test.ts
@@ -94,3 +94,30 @@ test("rehydration tolerates a malformed identities field", async () => {
assert.equal(state.activeIdentityID, seeded[1].id);
assert.equal(typeof state.insertIdentity, "function");
});
+
+test("removeIdentity drops the identity and moves the active slot if needed", () => {
+ useIdentityStore.setState({
+ activeIdentityID: seeded[0].id,
+ identities: [...seeded],
+ });
+ const { removeIdentity } = useIdentityStore.getState();
+
+ // Removing an inactive identity leaves the active one alone.
+ removeIdentity(seeded[2].id);
+ assert.deepEqual(
+ ids(useIdentityStore.getState().identities),
+ ids([seeded[0], seeded[1], seeded[3], seeded[4]]),
+ );
+ assert.equal(useIdentityStore.getState().activeIdentityID, seeded[0].id);
+
+ // Removing the active identity hands the slot to the most recent remaining one.
+ removeIdentity(seeded[0].id);
+ assert.equal(useIdentityStore.getState().activeIdentityID, seeded[1].id);
+
+ // Removing everything leaves no active identity.
+ for (const identity of [seeded[1], seeded[3], seeded[4]]) {
+ removeIdentity(identity.id);
+ }
+ assert.deepEqual(useIdentityStore.getState().identities, []);
+ assert.equal(useIdentityStore.getState().activeIdentityID, null);
+});