Skip to content
5 changes: 5 additions & 0 deletions .changeset/clever-masks-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@iota/apps-ui-kit': minor
---

fix checkbox alignment
4 changes: 2 additions & 2 deletions apps/ui-kit/src/lib/components/atoms/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
<span
onClick={() => inputRef.current?.click()}
className={cx(
'checkbox-base checkbox-state checkbox-icon checkbox-icon-hidden',
'checkbox-base checkbox-state checkbox-icon checkbox-icon-hidden shrink-0',
'checkbox-border-default',
'peer-[&:is(:checked,:indeterminate)]:checkbox-border-checked',
'peer-[&:is(:checked,:indeterminate)]:checkbox-bg-checked',
Expand All @@ -119,7 +119,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(

function LabelText({ label, name }: Pick<CheckboxProps, 'label' | 'name'>) {
return (
<label htmlFor={name} className="checkbox-label checkbox-label-disabled">
<label htmlFor={name} className="checkbox-label checkbox-label-disabled flex-1">
{label}
</label>
);
Expand Down
65 changes: 65 additions & 0 deletions apps/wallet/src/background/connections/uiConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import { accountsEvents } from '../accounts/events';
import { getAutoLockMinutes, notifyUserActive, setAutoLockMinutes } from '../autoLockAccounts';
import { backupDB, getDB, SETTINGS_KEYS } from '../db';
import { decrypt, encrypt } from '_src/shared/cryptography/keystore';
import { clearStatus, doMigration, getStatus } from '../storageMigration';
import NetworkEnv from '../networkEnv';
import { Connection } from './connection';
Expand Down Expand Up @@ -276,6 +277,70 @@ export class UiConnection extends Connection {
this.send(createMessage({ type: 'done' }, msg.id));
accountSourcesEvents.emit('accountSourcesChanged');
accountsEvents.emit('accountsChanged');
} else if (isMethodPayload(payload, 'changePassword')) {
const { currentPassword, newPassword } = payload.args;
const db = await getDB();
const allSources = await db.accountSources.toArray();
const allAccounts = await db.accounts.toArray();

let verified = false;
for (const source of allSources) {
try {
await decrypt(
currentPassword,
(source as unknown as { encryptedData: string }).encryptedData,
);
verified = true;
break;
} catch {
// continue to next
}
}
if (!verified) {
for (const account of allAccounts) {
const acc = account as unknown as { encrypted?: string };
if (acc.encrypted) {
try {
await decrypt(currentPassword, acc.encrypted);
verified = true;
break;
} catch {
// continue to next
}
}
}
}
if (!verified) {
throw new Error('Current password is incorrect');
}

await db.transaction('rw', db.accountSources, db.accounts, async () => {
for (const source of allSources) {
const src = source as unknown as { id: string; encryptedData: string };
const decrypted = await Dexie.waitFor(
decrypt(currentPassword, src.encryptedData),
);
const newEncryptedData = await Dexie.waitFor(
encrypt(newPassword, decrypted),
);
await db.accountSources.update(src.id, { encryptedData: newEncryptedData });
}
for (const account of allAccounts) {
const acc = account as { id: string; encrypted?: string };
if (acc.encrypted) {
const decrypted = await Dexie.waitFor(
decrypt(currentPassword, acc.encrypted),
);
const newEncrypted = await Dexie.waitFor(
encrypt(newPassword, decrypted),
);
await db.accounts.update(acc.id, { encrypted: newEncrypted });
}
}
});

await backupDB();
this.send(createMessage({ type: 'done' }, msg.id));
} else if (isDeriveBipPathAccountsFinder(payload)) {
const accountSource = await getAccountSourceByID(payload.sourceID);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ type MethodPayloads = {
data: PasswordRecoveryData;
};
removeAccount: { accountID: string };
changePassword: { currentPassword: string; newPassword: string };
};

type Methods = keyof MethodPayloads;
Expand Down
12 changes: 12 additions & 0 deletions apps/wallet/src/ui/app/background-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,18 @@ export class BackgroundClient {
);
}

public changePassword(args: MethodPayload<'changePassword'>['args']) {
return lastValueFrom(
this.sendMessage(
createMessage<MethodPayload<'changePassword'>>({
type: 'method-payload',
method: 'changePassword',
args,
}),
).pipe(take(1)),
);
}

public verifyPasswordRecoveryData(args: MethodPayload<'verifyPasswordRecoveryData'>['args']) {
return lastValueFrom(
this.sendMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,19 @@ import { useEffect } from 'react';
import { type SubmitHandler } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { z } from 'zod';
import zxcvbn from 'zxcvbn';
import { parseAutoLock, useAutoLockMinutes } from '_hooks';
import { CheckboxField } from '../../shared/forms/CheckboxField';
import { Form } from '../../shared/forms/Form';
import { validatePasswordStrength } from '../../shared/forms/passwordValidation';
import { AutoLockSelector, zodSchema } from './AutoLockSelector';
import { Button, ButtonHtmlType, ButtonType, Input, InputType } from '@iota/apps-ui-kit';
import { ExternalLink } from '_components';

function addDot(str: string | undefined) {
if (str && !str.endsWith('.')) {
return `${str}.`;
}
return str;
}

const formSchema = z
.object({
password: z
.object({
input: z
.string()
.nonempty('Required')
.superRefine((val, ctx) => {
const {
score,
feedback: { warning, suggestions },
} = zxcvbn(val);
if (score <= 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${addDot(warning) || 'Password is not strong enough.'}${
suggestions ? ` ${suggestions.join(' ')}` : ''
}`,
});
}
}),
input: z.string().nonempty('Required').superRefine(validatePasswordStrength),
confirmation: z.string().nonempty('Required'),
})
.refine(({ input, confirmation }) => input && confirmation && input === confirmation, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useEffect } from 'react';
import { useZodForm, toast } from '@iota/core';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { Button, ButtonHtmlType, ButtonType, Input, InputType } from '@iota/apps-ui-kit';
import { Overlay } from '_components';
import { Form } from '_src/ui/app/shared/forms/Form';
import { CheckboxField } from '_src/ui/app/shared/forms/CheckboxField';
import { validatePasswordStrength } from '_src/ui/app/shared/forms/passwordValidation';
import { useBackgroundClient } from '_src/ui/app/hooks/useBackgroundClient';

const formSchema = z
.object({
currentPassword: z.string().nonempty('Required'),
newPassword: z
.string()
.nonempty('Required')
.min(8, 'Must be at least 8 characters')
.superRefine(validatePasswordStrength),
confirmPassword: z.string().nonempty('Required'),
confirmed: z.boolean(),
})
.superRefine((data, ctx) => {
if (data.confirmPassword && data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmPassword'],
message: "Passwords don't match",
});
}
if (data.currentPassword && data.newPassword === data.currentPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['newPassword'],
message: 'New password must be different from current password',
});
}
if (!data.confirmed) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmed'],
message: 'You must confirm you understand this change',
});
}
});

type FormValues = z.infer<typeof formSchema>;

export function ChangePasswordSettings() {
const navigate = useNavigate();
const backgroundClient = useBackgroundClient();

const form = useZodForm({
mode: 'all',
schema: formSchema,
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
confirmed: false,
},
});

const {
register,
watch,
trigger,
getValues,
formState: { isSubmitting, isValid, errors },
} = form;

useEffect(() => {
const { unsubscribe } = watch((_, { name, type }) => {
if (type !== 'change') return;
if (name !== 'newPassword' && getValues('newPassword')) {
trigger('newPassword');
}
if (name !== 'confirmPassword' && getValues('confirmPassword')) {
trigger('confirmPassword');
}
});
return unsubscribe;
}, [watch, trigger, getValues]);

async function handleSubmit(values: FormValues) {
try {
await backgroundClient.changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
});
toast.success('Password updated successfully');
navigate(-1);
} catch (e) {
toast.error((e as Error)?.message || 'Failed to update password');
}
}

return (
<Overlay
showModal
title="Change Password"
closeOverlay={() => navigate('/tokens')}
showBackButton
>
<Form className="flex h-full flex-col gap-y-md" form={form} onSubmit={handleSubmit}>
<Input
type={InputType.Password}
isVisibilityToggleEnabled
label="Current password"
placeholder="********"
errorMessage={errors.currentPassword?.message}
{...register('currentPassword')}
data-amp-mask
/>
<Input
type={InputType.Password}
isVisibilityToggleEnabled
label="New password"
Comment thread
evavirseda marked this conversation as resolved.
placeholder="********"
errorMessage={errors.newPassword?.message}
{...register('newPassword')}
data-amp-mask
/>
<Input
type={InputType.Password}
isVisibilityToggleEnabled
label="Confirm new password"
placeholder="********"
errorMessage={errors.confirmPassword?.message}
{...register('confirmPassword')}
data-amp-mask
/>
<div className="flex-1" />
<CheckboxField
name="confirmed"
label="This password secures all accounts. If I forget it, IOTA cannot restore access"
/>
<Button
type={ButtonType.Primary}
htmlType={ButtonHtmlType.Submit}
text="Update Password"
disabled={!isValid || isSubmitting}
fullWidth
/>
</Form>
</Overlay>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DarkMode,
Globe,
Info,
Key,
LockLocked,
Logout,
Expand,
Expand Down Expand Up @@ -46,6 +47,7 @@ export function MenuList() {
const networkUrl = useNextMenuUrl(true, '/network');
const autoLockUrl = useNextMenuUrl(true, '/auto-lock');
const themeUrl = useNextMenuUrl(true, '/theme');
const changePasswordUrl = useNextMenuUrl(true, '/change-password');
const network = useAppSelector((state) => state.app.network);
const networkConfig = network === Network.Custom ? getCustomNetwork() : getNetwork(network);
const version = Browser.runtime.getManifest().version;
Expand Down Expand Up @@ -82,6 +84,10 @@ export function MenuList() {
function onThemeClick() {
navigate(themeUrl);
}

function onChangePasswordClick() {
navigate(changePasswordUrl);
}
async function onSidePanelClick(
_isToggled: boolean,
event: React.ChangeEvent<HTMLInputElement>,
Expand Down Expand Up @@ -133,6 +139,11 @@ export function MenuList() {
icon: <LockLocked />,
onClick: onAutoLockClick,
},
{
title: 'Change Password',
icon: <Key />,
onClick: onChangePasswordClick,
},
{
title: 'Themes',
icon: <DarkMode />,
Expand Down Expand Up @@ -175,7 +186,7 @@ export function MenuList() {
return (
<Overlay showModal title="Settings" closeOverlay={() => navigate('/tokens')}>
<div className="flex h-full w-full flex-col justify-between">
<div className="flex flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{MENU_ITEMS.filter((item) => !item.hidden).map((item, index) => (
<Card key={index} type={CardType.Default} onClick={item.onClick}>
<CardImage type={ImageType.BgSolid}>
Expand Down Expand Up @@ -217,7 +228,7 @@ export function MenuList() {
}}
/>
</div>
<div className="flex flex-col gap-y-lg">
<div className="flex flex-col gap-y-sm pt-sm">
<FaucetRequestButton />
<div className="flex flex-row items-center justify-center gap-x-md">
<span className="text-label-sm text-iota-neutral-40 dark:text-iota-neutral-60">
Expand Down
2 changes: 2 additions & 0 deletions apps/wallet/src/ui/app/components/menu/content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { AutoLockAccounts } from './AutoLockAccounts';
import { NetworkSettings } from './NetworkSettings';
import { MenuList } from './WalletSettingsMenuList';
import { ThemeSettings } from './ThemeSettings';
import { ChangePasswordSettings } from './ChangePasswordSettings';

const CLOSE_KEY_CODES: string[] = ['Escape'];

Expand Down Expand Up @@ -51,6 +52,7 @@ export function MenuContent() {
<Route path="/network" element={<NetworkSettings />} />
<Route path="/auto-lock" element={<AutoLockAccounts />} />
<Route path="/theme" element={<ThemeSettings />} />
<Route path="/change-password" element={<ChangePasswordSettings />} />
<Route path="*" element={<Navigate to={menuHomeUrl} replace={true} />} />
</Routes>
</MainLocationContext.Provider>
Expand Down
Loading
Loading