Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/wallet/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
ContinuityLinkModal,
CreateIdentityModal,
DisposeIdentityModal,
RemoveLocalIdentityModal,
RotateIdentityModal,
} from './components/flows';
import {
Expand Down Expand Up @@ -64,6 +65,7 @@ type Flow =
device: LocalIdentitySummary['issuedDevices'][number];
}
| { type: 'dispose'; identity: LocalIdentitySummary }
| { type: 'remove-local'; identity: LocalIdentitySummary }
| { type: 'rotate'; identity: LocalIdentitySummary }
| { type: 'continuity'; identity: LocalIdentitySummary };

Expand Down Expand Up @@ -169,6 +171,14 @@ function App() {
});
};

const removeLocalIdentity = async (identity: LocalIdentitySummary) => {
await walletAdapter.removeLocalIdentity(identity.localId);
setSelectedId(undefined);
await refresh();
setFlow(undefined);
setNotice({ kind: 'success', message: 'Identity removed from this wallet.' });
};

const rotateIdentity = async (identity: LocalIdentitySummary, input: RotateIdentityInput) => {
const created = await walletAdapter.rotate(identity.localId, input);
await refresh();
Expand Down Expand Up @@ -555,6 +565,7 @@ function App() {
onClose={() => setSelectedId(undefined)}
onRotate={() => setFlow({ type: 'rotate', identity: selectedIdentity })}
onDispose={() => setFlow({ type: 'dispose', identity: selectedIdentity })}
onRemoveLocal={() => setFlow({ type: 'remove-local', identity: selectedIdentity })}
onContinuity={() => setFlow({ type: 'continuity', identity: selectedIdentity })}
retryingRegistration={retryingRegistrationId === selectedIdentity.localId}
onRetryRegistration={() => void retryRegistration(selectedIdentity)}
Expand Down Expand Up @@ -632,6 +643,13 @@ function App() {
onDispose={() => disposeIdentity(flow.identity)}
/>
)}
{flow?.type === 'remove-local' && (
<RemoveLocalIdentityModal
identity={flow.identity}
onClose={() => setFlow(undefined)}
onRemove={() => removeLocalIdentity(flow.identity)}
/>
)}
{flow?.type === 'rotate' && (
<RotateIdentityModal
identity={flow.identity}
Expand Down
31 changes: 19 additions & 12 deletions apps/wallet/src/components/IdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,20 @@ export function IdentityCard({
const [copied, setCopied] = useState(false);
const active = identity.localState === 'active';
const deviceState = identity.device?.localState;
const statusLabel =
deviceState === 'pending-activation'
? 'Pending activation'
: deviceState === 'active'
? 'Active device'
: deviceState === 'revoked'
? 'Revoked device'
const deviceRevoked = deviceState === 'revoked' || identity.device?.registryState === 'revoked';
const deviceExpired =
identity.device !== undefined &&
(identity.device.registryState === 'expired' ||
Math.floor(Date.now() / 1_000) >= identity.device.expiresAt);
const terminal = !active || deviceRevoked;
const statusLabel = deviceRevoked
? 'Revoked device'
: deviceExpired
? 'Expired device'
: deviceState === 'pending-activation'
? 'Pending activation'
: deviceState === 'active'
? 'Active device'
: active
? identity.registered
? 'Active'
Expand All @@ -57,7 +64,7 @@ export function IdentityCard({
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
{...(active ? { whileHover: { y: -2 } } : {})}
{...(!terminal ? { whileHover: { y: -2 } } : {})}
className={`overflow-hidden rounded-2xl border bg-white transition-all ${
selected
? 'border-indigo-300 ring-2 ring-indigo-100'
Expand All @@ -68,10 +75,10 @@ export function IdentityCard({
<div className="flex items-start gap-3">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${
active ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-500'
!terminal ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-500'
}`}
>
{active ? (
{!terminal ? (
<KeyRound className="h-5 w-5" aria-hidden="true" />
) : (
<Unplug className="h-5 w-5" aria-hidden="true" />
Expand All @@ -84,7 +91,7 @@ export function IdentityCard({
</h2>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium ${
active
!terminal
? ready
? 'bg-emerald-100 text-emerald-800'
: 'bg-amber-100 text-amber-800'
Expand All @@ -93,7 +100,7 @@ export function IdentityCard({
>
<span
className={`h-1.5 w-1.5 rounded-full ${
active ? (ready ? 'bg-emerald-500' : 'bg-amber-500') : 'bg-rose-500'
!terminal ? (ready ? 'bg-emerald-500' : 'bg-amber-500') : 'bg-rose-500'
}`}
/>
{statusLabel}
Expand Down
52 changes: 43 additions & 9 deletions apps/wallet/src/components/IdentityDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { motion } from 'motion/react';
import type { LocalIdentitySummary } from '@nexus/wallet-core';

import {
canRemoveLocalIdentity,
canUseRegisteredIdentityActions,
needsRegistrationRecovery,
} from '../lib/identity-capabilities';
Expand All @@ -32,6 +33,7 @@ interface IdentityDetailProps {
onClose: () => void;
onRotate: () => void;
onDispose: () => void;
onRemoveLocal: () => void;
onContinuity: () => void;
retryingRegistration: boolean;
onRetryRegistration: () => void;
Expand All @@ -52,6 +54,7 @@ export function IdentityDetail({
onClose,
onRotate,
onDispose,
onRemoveLocal,
onContinuity,
retryingRegistration,
onRetryRegistration,
Expand All @@ -77,10 +80,19 @@ export function IdentityDetail({
const deviceCapabilities = deviceManagementCapabilities(identity, now);
const installedRegistryState = identity.device?.registryState;
const hasDeviceStatuses = identity.device !== undefined || identity.issuedDevices.length > 0;
const removableLocally = canRemoveLocalIdentity(identity, now);
const installedDeviceExpired =
identity.device !== undefined &&
(installedRegistryState === 'expired' || now >= identity.device.expiresAt);
const installedDeviceRevoked =
identity.device?.localState === 'revoked' || installedRegistryState === 'revoked';

useEffect(() => {
if (identity.device?.localState !== 'pending-activation') return;
const cutoff = Math.min(identity.device.activationDeadline, identity.device.expiresAt);
if (identity.device === undefined || identity.device.localState === 'revoked') return;
const cutoff =
identity.device.localState === 'pending-activation'
? Math.min(identity.device.activationDeadline, identity.device.expiresAt)
: identity.device.expiresAt;
if (now >= cutoff) return;
const delay = Math.min((cutoff - now) * 1_000 + 25, 2_147_483_647);
const handle = window.setTimeout(() => setNow(Math.floor(Date.now() / 1_000)), delay);
Expand Down Expand Up @@ -250,11 +262,13 @@ export function IdentityDetail({
<dd className="mt-2 flex items-center gap-2 text-sm font-semibold text-slate-900">
<ShieldCheck className="h-4 w-4 text-indigo-600" aria-hidden="true" />
{identity.device !== undefined
? identity.device.localState === 'active'
? 'Active delegated device'
: identity.device.localState === 'pending-activation'
? 'Delegated device pending activation'
: 'Delegated device revoked'
? installedDeviceRevoked
? 'Delegated device revoked'
: installedDeviceExpired
? 'Delegated device expired'
: identity.device.localState === 'active'
? 'Active delegated device'
: 'Delegated device pending activation'
: active && identity.registered
? 'Active and registered'
: active
Expand Down Expand Up @@ -326,9 +340,9 @@ export function IdentityDetail({
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900">
{installedRegistryState === 'revoked'
{installedDeviceRevoked
? 'Revoked delegated key'
: installedRegistryState === 'expired'
: installedDeviceExpired
? 'Expired delegated key'
: installedRegistryState === 'unknown'
? 'Not active at registry'
Expand Down Expand Up @@ -590,6 +604,26 @@ export function IdentityDetail({
</p>
</div>

{removableLocally && (
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-rose-200 bg-rose-50 p-4">
<div>
<h3 className="text-sm font-semibold text-rose-950">Remove local wallet entry</h3>
<p className="mt-1 text-xs leading-relaxed text-rose-800">
This identity is revoked or expired and can no longer create proofs. Remove its
local metadata and any remaining private material from this wallet.
</p>
</div>
<button
type="button"
onClick={onRemoveLocal}
disabled={deviceActionBusy || refreshingDevices}
className={smallDangerButton}
>
<Trash2 className="h-3.5 w-3.5" /> Remove from wallet
</button>
</div>
)}

{registeredActions && (
<div className="mt-6 grid gap-3 border-t border-slate-100 pt-6 sm:grid-cols-3">
<button
Expand Down
73 changes: 73 additions & 0 deletions apps/wallet/src/components/flows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,79 @@ export function DisposeIdentityModal({ identity, onClose, onDispose }: DisposeId
);
}

interface RemoveLocalIdentityModalProps {
identity: LocalIdentitySummary;
onClose: () => void;
onRemove: () => Promise<void>;
}

export function RemoveLocalIdentityModal({
identity,
onClose,
onRemove,
}: RemoveLocalIdentityModalProps) {
const [confirmed, setConfirmed] = useState(false);
const [state, setState] = useState<AsyncFormState>(idle);

const submit = async (event: FormEvent) => {
event.preventDefault();
if (!confirmed) return;
setState({ busy: true });
try {
await onRemove();
} catch (error) {
setState({ busy: false, error: errorMessage(error) });
}
};

return (
<ModalShell
title="Remove from this wallet"
description="Delete this revoked or expired identity's local wallet entry."
icon={<Trash2 className="h-5 w-5" aria-hidden="true" />}
onClose={onClose}
closeDisabled={state.busy}
>
<form onSubmit={(event) => void submit(event)} className="space-y-5">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm leading-relaxed text-amber-950">
<div className="mb-2 flex items-center gap-2 font-semibold">
<ShieldAlert className="h-4 w-4" aria-hidden="true" /> Local deletion only
</div>
This removes <strong>{identity.label ?? 'this identity'}</strong>, its nickname, history,
metadata, and any remaining private material from this wallet. It does not delete public
registry records or copies held by another wallet.
</div>
<label className="flex items-start gap-3 rounded-xl border border-rose-200 p-4">
<input
type="checkbox"
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
disabled={state.busy}
className="mt-1 accent-rose-600"
/>
<span className="text-sm font-medium leading-relaxed text-slate-800">
Remove this identity and its remaining local data from this wallet.
</span>
</label>
{state.error !== undefined && <ErrorNotice message={state.error} />}
<div className="flex gap-2">
<button type="button" onClick={onClose} disabled={state.busy} className={secondaryButton}>
Keep identity
</button>
<button
type="submit"
disabled={state.busy || !confirmed}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-rose-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:bg-rose-300"
>
{state.busy && <Loader2 className="h-4 w-4 animate-spin" />}
{state.busy ? 'Removing…' : 'Remove from wallet'}
</button>
</div>
</form>
</ModalShell>
);
}

interface RotateIdentityModalProps {
identity: LocalIdentitySummary;
onClose: () => void;
Expand Down
14 changes: 14 additions & 0 deletions apps/wallet/src/lib/identity-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';

import {
canCreateProof,
canRemoveLocalIdentity,
canUseRegisteredIdentityActions,
needsRegistrationRecovery,
} from './identity-capabilities';
Expand Down Expand Up @@ -74,5 +75,18 @@ describe('identity action capabilities', () => {
};

expect(canCreateProof(expiredDevice)).toBe(false);
expect(canRemoveLocalIdentity(expiredDevice, 1_799_999_999)).toBe(false);
expect(canRemoveLocalIdentity(expiredDevice, 1_800_000_000)).toBe(true);
expect(
canRemoveLocalIdentity({
...expiredDevice,
device: { ...expiredDevice.device!, registryState: 'revoked' },
}),
).toBe(true);
});

it('offers local removal for a revoked root identity but not an active root', () => {
expect(canRemoveLocalIdentity(identity('active', true))).toBe(false);
expect(canRemoveLocalIdentity(identity('revoked', true))).toBe(true);
});
});
15 changes: 15 additions & 0 deletions apps/wallet/src/lib/identity-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ export function canUseRegisteredIdentityActions(identity: LocalIdentitySummary):
// devices get their own self-revocation flow and must never be offered root operations.
return identity.device === undefined && identity.localState === 'active' && identity.registered;
}

export function canRemoveLocalIdentity(
identity: LocalIdentitySummary,
now = Math.floor(Date.now() / 1_000),
): boolean {
if (identity.localState === 'revoked') return true;
const device = identity.device;
return (
device !== undefined &&
(device.localState === 'revoked' ||
device.registryState === 'revoked' ||
device.registryState === 'expired' ||
now >= device.expiresAt)
);
}
4 changes: 4 additions & 0 deletions apps/wallet/src/lib/wallet-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ export const walletAdapter = {
});
},

removeLocalIdentity(localId: string): Promise<void> {
return walletCore.removeLocalIdentity(localId);
},

async rotate(oldLocalId: string, input: RotateIdentityInput): Promise<CreatedLocalIdentity> {
// Independent create + optional disposal deliberately emits no old/new link.
const created = await walletCore.createIdentity({
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ export interface WalletCoreApi {
clearAuthorizationHistory(localId: string): Promise<void>;
revoke(localId: string, options?: RevokeIdentityOptions): Promise<RegistryReceiptV1>;
dispose(localId: string, options?: DisposeIdentityOptions): Promise<RegistryReceiptV1>;
/** Deletes a terminally revoked or expired identity and its remaining material from this wallet. */
removeLocalIdentity(localId: string): Promise<void>;
rotate(
oldLocalId: string,
boundary: TrustedWalletEventBoundary,
Expand Down
Loading