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
15 changes: 14 additions & 1 deletion apps/wallet/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ function App() {
}
};

const renameIdentity = async (identity: LocalIdentitySummary, nickname?: string) => {
await walletAdapter.setLabel(identity.localId, nickname);
await refresh();
setNotice({
kind: 'success',
message:
nickname === undefined
? 'Local identity nickname removed.'
: `Local identity nickname changed to “${nickname}”.`,
});
};

const issueDevice = async (
identity: LocalIdentitySummary,
options: IssueDeviceTransferOptions,
Expand Down Expand Up @@ -392,7 +404,7 @@ function App() {
Identity wallet
</h2>
<p className="mt-1 text-sm text-slate-500">
Local labels and app scopes never leave this wallet.
Local nicknames and app scopes never leave this wallet.
</p>
</div>
<div className="flex gap-2 text-xs font-medium text-slate-600">
Expand Down Expand Up @@ -485,6 +497,7 @@ function App() {
onRetryRegistration={() => void retryRegistration(selectedIdentity)}
clearingHistory={clearingHistoryId === selectedIdentity.localId}
onClearHistory={() => clearAuthorizationHistory(selectedIdentity)}
onRename={(nickname) => renameIdentity(selectedIdentity, nickname)}
onAddDevice={() => setFlow({ type: 'issue-device', identity: selectedIdentity })}
onActivateDevice={() => activateDevice(selectedIdentity)}
onSelfRevokeDevice={() =>
Expand Down
2 changes: 1 addition & 1 deletion apps/wallet/src/components/DeviceManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa
<TransferMethodSelector method={method} onChange={setMethod} disabled={state.busy} />
<div>
<label htmlFor="device-label" className="ml-1 block text-sm font-medium text-slate-700">
Device label <span className="font-normal text-slate-400">(local only)</span>
Device nickname <span className="font-normal text-slate-400">(local only)</span>
</label>
<input
id="device-label"
Expand Down
106 changes: 100 additions & 6 deletions apps/wallet/src/components/IdentityDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
KeySquare,
Link2,
Loader2,
Pencil,
Plus,
RefreshCw,
ShieldCheck,
Expand Down Expand Up @@ -36,6 +37,7 @@ interface IdentityDetailProps {
onRetryRegistration: () => void;
clearingHistory: boolean;
onClearHistory: () => Promise<void>;
onRename: (nickname?: string) => Promise<void>;
onAddDevice: () => void;
onActivateDevice: () => Promise<void>;
onSelfRevokeDevice: () => void;
Expand All @@ -53,6 +55,7 @@ export function IdentityDetail({
onRetryRegistration,
clearingHistory,
onClearHistory,
onRename,
onAddDevice,
onActivateDevice,
onSelfRevokeDevice,
Expand All @@ -62,6 +65,10 @@ export function IdentityDetail({
const active = identity.localState === 'active';
const registeredActions = canUseRegisteredIdentityActions(identity);
const [confirmClearHistory, setConfirmClearHistory] = useState(false);
const [editingNickname, setEditingNickname] = useState(false);
const [nickname, setNickname] = useState(identity.label ?? '');
const [renaming, setRenaming] = useState(false);
const [renameError, setRenameError] = useState<string>();
const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000));
const deviceCapabilities = deviceManagementCapabilities(identity, now);

Expand All @@ -74,6 +81,34 @@ export function IdentityDetail({
return () => window.clearTimeout(handle);
}, [identity.device, now]);

useEffect(() => {
if (!editingNickname) setNickname(identity.label ?? '');
}, [editingNickname, identity.label]);

const saveNickname = async () => {
const normalized = nickname.trim();
if (normalized.length > 128) {
setRenameError('The nickname cannot exceed 128 characters.');
return;
}
setRenaming(true);
setRenameError(undefined);
try {
await onRename(normalized === '' ? undefined : normalized);
setEditingNickname(false);
} catch (error) {
setRenameError(error instanceof Error ? error.message : 'The nickname could not be saved.');
} finally {
setRenaming(false);
}
};

const cancelNickname = () => {
setNickname(identity.label ?? '');
setRenameError(undefined);
setEditingNickname(false);
};

const clearHistory = async () => {
if (!confirmClearHistory) {
setConfirmClearHistory(true);
Expand Down Expand Up @@ -101,12 +136,71 @@ export function IdentityDetail({
<p className="text-xs font-semibold uppercase tracking-wider text-indigo-600">
Local identity
</p>
<h2
id="identity-details-title"
className="mt-0.5 truncate text-xl font-bold text-slate-900"
>
{identity.label ?? 'Untitled identity'}
</h2>
<div className="mt-0.5 flex items-center gap-2">
<h2
id="identity-details-title"
className="truncate text-xl font-bold text-slate-900"
>
{identity.label ?? 'Untitled identity'}
</h2>
{!editingNickname && (
<button
type="button"
onClick={() => setEditingNickname(true)}
className="shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-indigo-50 hover:text-indigo-700"
aria-label="Edit identity nickname"
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</div>
{editingNickname && (
<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
void saveNickname();
}}
>
<label htmlFor="identity-nickname" className="sr-only">
Identity nickname
</label>
<div className="flex flex-wrap gap-2">
<input
id="identity-nickname"
autoFocus
value={nickname}
onChange={(event) => setNickname(event.target.value)}
maxLength={128}
autoComplete="off"
placeholder="Nickname on this wallet"
disabled={renaming}
className="min-w-48 flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
/>
<button type="submit" disabled={renaming} className={smallPrimaryButton}>
{renaming && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{renaming ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={cancelNickname}
disabled={renaming}
className="rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
>
Cancel
</button>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-slate-400">
Stored only in this wallet. It does not change your identity or appear in
proofs.
</p>
{renameError !== undefined && (
<p role="alert" className="mt-1.5 text-xs font-medium text-rose-700">
{renameError}
</p>
)}
</form>
)}
</div>
</div>
<button
Expand Down
10 changes: 7 additions & 3 deletions apps/wallet/src/components/ModalShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export function ModalShell({
const titleId = useId();
const descriptionId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const closeDisabledRef = useRef(closeDisabled);
onCloseRef.current = onClose;
closeDisabledRef.current = closeDisabled;

useEffect(() => {
const previousFocus =
Expand All @@ -37,9 +41,9 @@ export function ModalShell({
document.body.style.overflow = 'hidden';

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !closeDisabled) {
if (event.key === 'Escape' && !closeDisabledRef.current) {
event.preventDefault();
onClose();
onCloseRef.current();
return;
}
if (event.key !== 'Tab' || panel === null) return;
Expand All @@ -66,7 +70,7 @@ export function ModalShell({
document.body.style.overflow = previousOverflow;
previousFocus?.focus();
};
}, [closeDisabled, onClose]);
}, []);

return (
<motion.div
Expand Down
4 changes: 2 additions & 2 deletions apps/wallet/src/components/flows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function CreateIdentityModal({ onClose, onCreate }: CreateIdentityModalPr
<form onSubmit={(event) => void submit(event)} className="space-y-5">
<div>
<label htmlFor="identity-label" className="ml-1 block text-sm font-medium text-slate-700">
Local label <span className="font-normal text-slate-400">(optional)</span>
Local nickname <span className="font-normal text-slate-400">(optional)</span>
</label>
<input
id="identity-label"
Expand Down Expand Up @@ -241,7 +241,7 @@ export function RotateIdentityModal({ identity, onClose, onRotate }: RotateIdent
</div>
<div>
<label htmlFor="rotated-label" className="ml-1 block text-sm font-medium text-slate-700">
New local label
New local nickname
</label>
<input
id="rotated-label"
Expand Down
6 changes: 6 additions & 0 deletions packages/wallet-core/src/wallet-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,12 @@ export class WalletCore implements WalletCoreApi {
}

public async setLabel(localId: string, label?: string): Promise<void> {
if (label !== undefined && (label.length === 0 || label.length > 128)) {
throw new WalletCoreError(
'INVALID_REQUEST',
'A local identity nickname must contain between 1 and 128 characters.',
);
}
await this.#updateRecord(localId, (latest) => {
if (label !== undefined) return { ...latest, label };
const withoutLabel = { ...latest };
Expand Down
10 changes: 10 additions & 0 deletions packages/wallet-core/test/device-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,16 @@ describe('v2 root-authorized devices', () => {
);
expect(importedSummary?.hasAgreementKey).toBe(false);

await wallet.setLabel(imported.localId, 'Travel phone');
expect(
(await wallet.listIdentitySummaries()).find(
(summary) => summary.localId === imported.localId,
),
).toMatchObject({ label: 'Travel phone' });
await expect(wallet.setLabel(imported.localId, 'x'.repeat(129))).rejects.toMatchObject({
code: 'INVALID_REQUEST',
});

const proofRequest = proofRequestSchema.parse({
action: 'post.edit',
resource: 'post:01JABC',
Expand Down
13 changes: 10 additions & 3 deletions tests/browser-e2e/security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ test.describe.serial('Nexus browser security boundary', () => {
wallet.getByRole('heading', { name: 'Choose who you are, each time.' }),
).toBeVisible();
await wallet.getByRole('button', { name: 'Create first identity' }).click();
await wallet.getByLabel('Local label (optional)').fill(IDENTITY_LABEL);
await wallet.getByLabel('Local nickname (optional)').fill(IDENTITY_LABEL);
await wallet.getByRole('button', { name: 'Create identity' }).click();
await expect(wallet.getByRole('heading', { name: IDENTITY_LABEL }).first()).toBeVisible();
await wallet.close();
Expand Down Expand Up @@ -195,7 +195,7 @@ test.describe.serial('Nexus browser security boundary', () => {
const wallet = await v2Context.newPage();
await wallet.goto(WALLET_ORIGIN);
await wallet.getByRole('button', { name: 'Create first identity' }).click();
await wallet.getByLabel('Local label (optional)').fill('E2E v2 root');
await wallet.getByLabel('Local nickname (optional)').fill('E2E v2 root');
await wallet.getByRole('button', { name: 'Create identity' }).click();
await expect(wallet.getByRole('heading', { name: 'E2E v2 root' }).first()).toBeVisible();

Expand All @@ -218,7 +218,10 @@ test.describe.serial('Nexus browser security boundary', () => {
await wallet.getByRole('button', { name: 'View details' }).click();
await expect(wallet.getByRole('button', { name: 'Hide details' })).toBeVisible();
await wallet.getByRole('button', { name: 'Add device' }).click();
await wallet.getByLabel('Device label').fill('E2E delegated device');
const deviceNickname = wallet.getByLabel('Device nickname');
await deviceNickname.focus();
await deviceNickname.pressSequentially('E2E delegated device');
await expect(deviceNickname).toBeFocused();
await wallet.getByRole('checkbox').check();
await wallet.getByRole('button', { name: 'Create device transfer' }).click();
await expect(wallet.getByText('Device authorized', { exact: true })).toBeVisible();
Expand All @@ -239,6 +242,10 @@ test.describe.serial('Nexus browser security boundary', () => {
wallet.getByText('Device installed and activated for this identity.', { exact: true }),
).toBeVisible();
await expect(wallet.getByText('Active device', { exact: true })).toBeVisible();
await wallet.getByLabel('Edit identity nickname').click();
await wallet.getByLabel('Identity nickname').fill('E2E phone');
await wallet.getByRole('button', { name: 'Save', exact: true }).click();
await expect(wallet.getByRole('heading', { name: 'E2E phone' }).first()).toBeVisible();

await rp.reload();
const popupPromise = rp.waitForEvent('popup');
Expand Down