From d41979b676ef48f2c31193cd4288cf2bd27768a7 Mon Sep 17 00:00:00 2001 From: darkruby Date: Fri, 7 Aug 2026 21:47:01 +0100 Subject: [PATCH 1/7] https://github.com/venil7/assets/issues/38 --- packages/core/src/decoders/yahoo/chart.ts | 10 +-- packages/core/src/decoders/yahoo/meta.ts | 2 +- packages/core/test/data/OBNRX.json | 69 +++++++++++++++++++ .../web/src/components/Tx/TickerLookup.tsx | 6 +- 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 packages/core/test/data/OBNRX.json diff --git a/packages/core/src/decoders/yahoo/chart.ts b/packages/core/src/decoders/yahoo/chart.ts index a36012d1..07dc8f20 100644 --- a/packages/core/src/decoders/yahoo/chart.ts +++ b/packages/core/src/decoders/yahoo/chart.ts @@ -15,11 +15,11 @@ import { ChartMetaDecoder } from "./meta"; import { type PeriodChangesDecoder } from "./period"; const QuoteDecoder = t.type({ - open: t.array(nullableDecoder(t.number)), - low: t.array(nullableDecoder(t.number)), - high: t.array(nullableDecoder(t.number)), - close: t.array(nullableDecoder(t.number)), - volume: t.array(nullableDecoder(t.number)) + open: withFallback(t.array(nullableDecoder(t.number)), []), + low: withFallback(t.array(nullableDecoder(t.number)), []), + high: withFallback(t.array(nullableDecoder(t.number)), []), + close: withFallback(t.array(nullableDecoder(t.number)), []), + volume: withFallback(t.array(nullableDecoder(t.number)), []) }); const IndicatorsDecoder = t.type({ diff --git a/packages/core/src/decoders/yahoo/meta.ts b/packages/core/src/decoders/yahoo/meta.ts index 5fe34f15..44189339 100644 --- a/packages/core/src/decoders/yahoo/meta.ts +++ b/packages/core/src/decoders/yahoo/meta.ts @@ -85,7 +85,7 @@ export const ChartMetaDecoder = t.type({ regularMarketPrice: t.number, fiftyTwoWeekHigh: t.number, fiftyTwoWeekLow: t.number, - shortName: t.string, + shortName: nullableDecoder(t.string), longName: nullableDecoder(t.string), previousClose: nullableDecoder(t.number), chartPreviousClose: t.number, diff --git a/packages/core/test/data/OBNRX.json b/packages/core/test/data/OBNRX.json new file mode 100644 index 00000000..974b9465 --- /dev/null +++ b/packages/core/test/data/OBNRX.json @@ -0,0 +1,69 @@ +{ + "chart": { + "result": [ + { + "meta": { + "currency": "CAD", + "symbol": "OBNRX", + "exchangeName": "NAS", + "fullExchangeName": "Nasdaq", + "instrumentType": "MUTUALFUND", + "regularMarketPrice": 10.78, + "firstTradeDate": 1769697000, + "regularMarketTime": 1786018765, + "hasPrePostMarketData": false, + "gmtoffset": -14400, + "timezone": "EDT", + "exchangeTimezoneName": "America/New_York", + "fiftyTwoWeekHigh": 11.0389, + "fiftyTwoWeekLow": 10.6822, + "longName": "RBC Canadian Government Bond Index Fund Series DZ NL (1973A3)", + "chartPreviousClose": 10.7788, + "priceHint": 2, + "currentTradingPeriod": { + "pre": { + "timezone": "EDT", + "end": 1786023000, + "start": 1786003200, + "gmtoffset": -14400 + }, + "regular": { + "timezone": "EDT", + "end": 1786046400, + "start": 1786023000, + "gmtoffset": -14400 + }, + "post": { + "timezone": "EDT", + "end": 1786060800, + "start": 1786046400, + "gmtoffset": -14400 + } + }, + "dataGranularity": "1d", + "range": "1d", + "validRanges": [ + "1mo", + "3mo", + "6mo", + "ytd", + "1y", + "2y", + "5y", + "10y", + "max" + ] + }, + "indicators": { + "quote": [ + {} + ], + "adjclose": [ + {} + ] + } + } + ], + "error": null + } +} \ No newline at end of file diff --git a/packages/web/src/components/Tx/TickerLookup.tsx b/packages/web/src/components/Tx/TickerLookup.tsx index 16ce3749..2e932d50 100644 --- a/packages/web/src/components/Tx/TickerLookup.tsx +++ b/packages/web/src/components/Tx/TickerLookup.tsx @@ -13,8 +13,8 @@ type SelectOption = { }; const toOptions = (ticker: Ticker): SelectOption => ({ - label: `(${ticker.symbol}) ${ticker.shortname} - ${ticker.quoteType} - ${ticker.exchange}`, - value: ticker, + label: `(${ticker.symbol}) ${ticker.shortname ?? ticker.longname} - ${ticker.quoteType} - ${ticker.exchange}`, + value: ticker }); const lookup = (s: string) => @@ -31,7 +31,7 @@ export type TickerLookupProps = { export const TickerLookup: React.FC = ({ onSelect, - disabled, + disabled }) => { return ( Date: Tue, 11 Aug 2026 22:48:06 +0100 Subject: [PATCH 2/7] asmin: wired up user pwd change --- packages/core/src/domain/user.ts | 15 ++++--- packages/core/src/http/api.ts | 5 ++- packages/web/src/components/Asset/Menu.tsx | 1 + .../web/src/components/Portfolio/Menu.tsx | 3 +- packages/web/src/components/Tx/Menu.tsx | 1 + packages/web/src/components/Users/Menu.tsx | 30 ++++++++++++++ .../components/{Profile => Users}/NewUser.tsx | 2 +- .../UserForm.tsx => Users/UserProfile.tsx} | 26 ++++++++----- packages/web/src/components/Users/Users.tsx | 39 ++++++++++++------- packages/web/src/screens/Users.tsx | 1 + packages/web/src/services/users.ts | 29 +++++++++++--- packages/web/src/stores/users.ts | 34 +++++++++------- 12 files changed, 134 insertions(+), 52 deletions(-) create mode 100644 packages/web/src/components/Users/Menu.tsx rename packages/web/src/components/{Profile => Users}/NewUser.tsx (99%) rename packages/web/src/components/{Profile/UserForm.tsx => Users/UserProfile.tsx} (72%) diff --git a/packages/core/src/domain/user.ts b/packages/core/src/domain/user.ts index 33465d11..9cc89e84 100644 --- a/packages/core/src/domain/user.ts +++ b/packages/core/src/domain/user.ts @@ -8,7 +8,7 @@ import type { ProfileDecoder, RawInUserDecoder, RawOutUserDecoder, - UserIdDecoder, + UserIdDecoder } from "../decoders/user"; export type Credentials = t.TypeOf; @@ -24,22 +24,27 @@ export type UserId = t.TypeOf; export const profile = ({ id, username, admin }: GetUser): Profile => ({ id, username, - admin, + admin }); export const defaultCredentials = (): Credentials => ({ username: "", - password: "", + password: "" }); export const defaultNewUser = (): NewUser => ({ ...defaultCredentials(), admin: false, - locked: false, + locked: false }); export const defaultPasswordChange = (): PasswordChange => ({ oldPassword: "", newPassword: "", - repeat: "", + repeat: "" +}); + +export const adminPasswordChange = (): PasswordChange => ({ + ...defaultPasswordChange(), + oldPassword: `oldpassword` }); diff --git a/packages/core/src/http/api.ts b/packages/core/src/http/api.ts index 9e6eba3f..fc5c32de 100644 --- a/packages/core/src/http/api.ts +++ b/packages/core/src/http/api.ts @@ -236,6 +236,8 @@ const getApi = (baseUrl: string) => (methods: rest.Methods) => { methods.get(USER_URL(uid), GetUserDecoder); const deleteUser = (uid: UserId) => methods.delete(USER_URL(uid), IdDecoder); + const passwordUser = (uid: UserId, body: PasswordChange) => + methods.patch(USER_URL(uid), body, GetUserDecoder); const lookupTicker = (ticker: string) => methods.get( @@ -253,7 +255,8 @@ const getApi = (baseUrl: string) => (methods: rest.Methods) => { getMany: getUsers, update: updateUser, create: createUser, - delete: deleteUser + delete: deleteUser, + password: passwordUser }, profile: { get: getProfile, diff --git a/packages/web/src/components/Asset/Menu.tsx b/packages/web/src/components/Asset/Menu.tsx index 9bc851ae..f7dc1e02 100644 --- a/packages/web/src/components/Asset/Menu.tsx +++ b/packages/web/src/components/Asset/Menu.tsx @@ -16,6 +16,7 @@ export const AssetMenu: React.FC = ({ }: AssetMenuProps) => { return ( }> + Asset actions Edit diff --git a/packages/web/src/components/Portfolio/Menu.tsx b/packages/web/src/components/Portfolio/Menu.tsx index fe31f957..99069e1c 100644 --- a/packages/web/src/components/Portfolio/Menu.tsx +++ b/packages/web/src/components/Portfolio/Menu.tsx @@ -8,10 +8,11 @@ type PortfolioMenuProps = { export const PortfolioMenu: React.FC = ({ onDelete, - onEdit, + onEdit }: PortfolioMenuProps) => { return ( }> + Portfolio actions Edit diff --git a/packages/web/src/components/Tx/Menu.tsx b/packages/web/src/components/Tx/Menu.tsx index ca2a3fc8..d2c81af7 100644 --- a/packages/web/src/components/Tx/Menu.tsx +++ b/packages/web/src/components/Tx/Menu.tsx @@ -14,6 +14,7 @@ export const TxMenu: React.FC = ({ }: TxMenuProps) => { return ( }> + Tx actions Edit diff --git a/packages/web/src/components/Users/Menu.tsx b/packages/web/src/components/Users/Menu.tsx new file mode 100644 index 00000000..83843cb4 --- /dev/null +++ b/packages/web/src/components/Users/Menu.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { Dropdown, DropdownButton } from "react-bootstrap"; + +type UserMenuProps = { + onEditProfile: () => void; + onChangePassword: () => void; + onDelete: () => void; +}; + +export const UserMenu: React.FC = ({ + onDelete, + onEditProfile, + onChangePassword +}: UserMenuProps) => { + return ( + }> + Actions + + Edit profile + + + Update password + + + + Delete + + + ); +}; diff --git a/packages/web/src/components/Profile/NewUser.tsx b/packages/web/src/components/Users/NewUser.tsx similarity index 99% rename from packages/web/src/components/Profile/NewUser.tsx rename to packages/web/src/components/Users/NewUser.tsx index fa404474..d0f35673 100644 --- a/packages/web/src/components/Profile/NewUser.tsx +++ b/packages/web/src/components/Users/NewUser.tsx @@ -61,7 +61,7 @@ export const NewUserForm = createForm(NewUserFields, newUserValidator); export const NewUserModal = createModal( NewUserFields, newUserValidator, - "User" + "New user" ); export const newUserModal = (value: NewUser) => diff --git a/packages/web/src/components/Profile/UserForm.tsx b/packages/web/src/components/Users/UserProfile.tsx similarity index 72% rename from packages/web/src/components/Profile/UserForm.tsx rename to packages/web/src/components/Users/UserProfile.tsx index f4bd2d8e..83b30d19 100644 --- a/packages/web/src/components/Profile/UserForm.tsx +++ b/packages/web/src/components/Users/UserProfile.tsx @@ -9,13 +9,13 @@ import { createForm, type FieldsProps } from "../Form/Form"; import { CheckBox, FormEdit } from "../Form/FormControl"; import { createModal } from "../Modals/Modal"; -type UserFieldsProps = FieldsProps; +type UserProfileFieldsProps = FieldsProps; -export const UserFields: React.FC = ({ +export const UserProfileFields: React.FC = ({ data, onChange, - disabled, -}: UserFieldsProps) => { + disabled +}: UserProfileFieldsProps) => { const setField = usePartialChange(data, onChange); return (
@@ -47,13 +47,19 @@ export const UserFields: React.FC = ({ ); }; -export const UserForm = createForm(UserFields, postUserValidator); +export const UserProfileForm = createForm( + UserProfileFields, + postUserValidator +); -export const UserModal = createModal( - UserFields, +export const UserProfileModal = createModal( + UserProfileFields, postUserValidator, - "User" + "User profile" ); -export const userModal = (value: PostUser) => - pipe({ value }, createDialog>(UserModal)); +export const userProfileModal = (value: PostUser) => + pipe( + { value }, + createDialog>(UserProfileModal) + ); diff --git a/packages/web/src/components/Users/Users.tsx b/packages/web/src/components/Users/Users.tsx index beb5e349..358ba4e2 100644 --- a/packages/web/src/components/Users/Users.tsx +++ b/packages/web/src/components/Users/Users.tsx @@ -1,9 +1,11 @@ import { + adminPasswordChange, defaultNewUser, type GetUser, type NewUser, + type PasswordChange, type PostUser, - type UserId, + type UserId } from "@darkruby/assets-core"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; @@ -16,14 +18,16 @@ import { yesNo } from "../../util/yesno"; import { AddBtn } from "../Form/Button"; import { HorizontalStack } from "../Layout/Stack"; import { confirmationModal } from "../Modals/Confirmation"; -import { PortfolioMenu } from "../Portfolio/Menu"; -import { newUserModal } from "../Profile/NewUser"; -import { userModal } from "../Profile/UserForm"; +import { passwordChangeModal } from "../Profile/PasswordChange"; +import { UserMenu } from "./Menu"; +import { newUserModal } from "./NewUser"; +import { userProfileModal } from "./UserProfile"; type UsersProps = { users: GetUser[]; onAdd: (p: NewUser) => void; onUpdate: (uid: UserId, p: PostUser) => void; + onPasswordChange: (uid: UserId, p: PasswordChange) => void; onDelete: (uid: UserId) => void; disabled?: boolean; }; @@ -32,18 +36,26 @@ const RawUsers: React.FC = ({ users, onAdd, onUpdate, + onPasswordChange, onDelete, - disabled, + disabled }: UsersProps) => { - const handleUpdate = (user: GetUser) => + const handleProfileUpdate = (user: GetUser) => pipe( - () => userModal(user), + () => userProfileModal(user), TE.map((updated) => onUpdate(user.id, updated)) ); - const handleDelete = (uid: UserId) => + + const handlePasswordUpdate = (user: GetUser) => + pipe( + () => passwordChangeModal(adminPasswordChange()), + TE.map((password) => onPasswordChange(user.id, password)) + ); + + const handleDelete = ({ id, username }: GetUser) => pipe( - () => confirmationModal(`Delete user?`), - TE.chainIOK(() => () => onDelete(uid)) + () => confirmationModal(`Delete user '${username}'?`), + TE.chainIOK(() => () => onDelete(id)) ); const handleAdd = pipe(() => newUserModal(defaultNewUser()), TE.map(onAdd)); @@ -71,9 +83,10 @@ const RawUsers: React.FC = ({ {yesNo(user.admin)} {yesNo(user.login_attempts > 3 || user.locked)} - diff --git a/packages/web/src/screens/Users.tsx b/packages/web/src/screens/Users.tsx index 07f1efbd..1982fdc3 100644 --- a/packages/web/src/screens/Users.tsx +++ b/packages/web/src/screens/Users.tsx @@ -30,6 +30,7 @@ const RawUsersScreen: React.FC = () => { error={users.error.value} onErrorDismiss={users.load} fetching={users.fetching.value} + onPasswordChange={users.password} /> ); }; diff --git a/packages/web/src/services/users.ts b/packages/web/src/services/users.ts index 54e2cac3..11d13769 100644 --- a/packages/web/src/services/users.ts +++ b/packages/web/src/services/users.ts @@ -3,45 +3,62 @@ import type { GetUser, Id, NewUser, + PasswordChange, PostUser, Profile, - UserId, + UserId } from "@darkruby/assets-core"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getUsers = (): Action => { +const getMany = (): Action => { return pipe( apiFromToken, TE.chain(({ user }) => user.getMany()) ); }; -export const getUser = (uid: UserId): Action => { +const get = (uid: UserId): Action => { return pipe( apiFromToken, TE.chain(({ user }) => user.get(uid)) ); }; -export const createUser = (creds: NewUser): Action => { +const create = (creds: NewUser): Action => { return pipe( apiFromToken, TE.chain(({ user }) => user.create(creds)) ); }; -export const updateUser = (uid: UserId, creds: PostUser): Action => { +const update = (uid: UserId, creds: PostUser): Action => { return pipe( apiFromToken, TE.chain(({ user }) => user.update(uid, creds)) ); }; -export const deleteUser = (uid: UserId): Action => { +const password = (uid: UserId, pwd: PasswordChange): Action => { + return pipe( + apiFromToken, + TE.chain(({ user }) => user.password(uid, pwd)) + ); +}; + +const delete1 = (uid: UserId): Action => { return pipe( apiFromToken, TE.chain(({ user }) => user.delete(uid)) ); }; + +export const users = { + get, + getMany, + create, + update, + delete: delete1, + password +}; diff --git a/packages/web/src/stores/users.ts b/packages/web/src/stores/users.ts index f3c98d1e..4f33d018 100644 --- a/packages/web/src/stores/users.ts +++ b/packages/web/src/stores/users.ts @@ -3,18 +3,14 @@ import type { GetUser, Identity, NewUser, + PasswordChange, PostUser, - UserId, + UserId } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; -import { - createUser, - deleteUser, - getUsers, - updateUser, -} from "../services/users"; +import { users } from "../services/users"; import { type StoreBase, createStoreBase } from "./base"; export type UsersStore = Identity< @@ -22,6 +18,7 @@ export type UsersStore = Identity< load: () => ActionResult; create: (creds: NewUser) => ActionResult; update: (uid: UserId, credes: PostUser) => ActionResult; + password: (uid: UserId, credes: PasswordChange) => ActionResult; delete: (uid: UserId) => ActionResult; } >; @@ -32,27 +29,34 @@ export const createUsersStore = (): UsersStore => { return { ...storeBase, - load: () => storeBase.run(getUsers()), + load: () => storeBase.run(users.getMany()), create: (creds: NewUser) => storeBase.run( pipe( - createUser(creds), - TE.chain(() => getUsers()) + users.create(creds), + TE.chain(() => users.getMany()) ) ), update: (uid: UserId, creds: PostUser) => storeBase.run( pipe( - updateUser(uid, creds), - TE.chain(() => getUsers()) + users.update(uid, creds), + TE.chain(() => users.getMany()) ) ), - delete: (uid: UserId) => + password: (uid: UserId, pwd: PasswordChange) => storeBase.run( pipe( - deleteUser(uid), - TE.chain(() => getUsers()) + users.password(uid, pwd), + TE.chain(() => users.getMany()) ) ), + delete: (uid: UserId) => + storeBase.run( + pipe( + users.delete(uid), + TE.chain(() => users.getMany()) + ) + ) }; }; From 7c29360ea9b3110ac4f48541ae62d81485ac7d31 Mon Sep 17 00:00:00 2001 From: darkruby Date: Tue, 11 Aug 2026 23:00:22 +0100 Subject: [PATCH 3/7] refactor web service/store exports --- .../components/Portfolio/PortfoliosSelect.tsx | 4 +- .../web/src/components/Tx/TickerLookup.tsx | 4 +- packages/web/src/components/Tx/TxFields.tsx | 6 +-- packages/web/src/services/csv.ts | 0 packages/web/src/services/portfolios.ts | 30 +++++++-------- packages/web/src/services/prefs.ts | 9 ++++- packages/web/src/services/profile.ts | 17 ++++++--- packages/web/src/services/summary.ts | 6 ++- packages/web/src/services/ticker.ts | 12 ++++-- packages/web/src/services/txs.ts | 38 +++++++++---------- packages/web/src/stores/portfolio.ts | 19 ++++------ packages/web/src/stores/portfolios.ts | 25 +++++------- packages/web/src/stores/prefs.ts | 8 ++-- packages/web/src/stores/profile.ts | 19 ++++------ packages/web/src/stores/summary.ts | 4 +- packages/web/src/stores/tx.ts | 14 +++---- packages/web/src/stores/txs.ts | 32 +++++++--------- 17 files changed, 121 insertions(+), 126 deletions(-) delete mode 100644 packages/web/src/services/csv.ts diff --git a/packages/web/src/components/Portfolio/PortfoliosSelect.tsx b/packages/web/src/components/Portfolio/PortfoliosSelect.tsx index 03214184..8f469007 100644 --- a/packages/web/src/components/Portfolio/PortfoliosSelect.tsx +++ b/packages/web/src/components/Portfolio/PortfoliosSelect.tsx @@ -5,7 +5,7 @@ import * as TE from "fp-ts/lib/TaskEither"; import { useEffect, useState } from "react"; import { Form } from "react-bootstrap"; import { withProps } from "../../decorators/props"; -import { getPortfolios } from "../../services/portfolios"; +import { portfolios as portfoliosSvc } from "../../services/portfolios"; import { createDialog } from "../../util/modal"; import type { PropsOf } from "../../util/props"; import { createForm, type FieldsProps } from "../Form/Form"; @@ -30,7 +30,7 @@ export const PortfoliosSelectFields: React.FC = ({ }) => { const [portfolios, setPortfolios] = useState([]); useEffect(() => { - pipe(getPortfolios(), TE.map(setPortfolios))(); + pipe(portfoliosSvc.getMany(), TE.map(setPortfolios))(); }, []); return ( diff --git a/packages/web/src/components/Tx/TickerLookup.tsx b/packages/web/src/components/Tx/TickerLookup.tsx index 2e932d50..7f158033 100644 --- a/packages/web/src/components/Tx/TickerLookup.tsx +++ b/packages/web/src/components/Tx/TickerLookup.tsx @@ -4,7 +4,7 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import * as React from "react"; import AsyncSelect from "react-select/async"; -import { lookupTicker } from "../../services/ticker"; +import { ticker } from "../../services/ticker"; import "./TickerLookup.scss"; type SelectOption = { @@ -19,7 +19,7 @@ const toOptions = (ticker: Ticker): SelectOption => ({ const lookup = (s: string) => pipe( - lookupTicker(s), + ticker.lookup(s), TE.map((x) => A.map(toOptions)(x.quotes)), TE.getOrElse(() => () => Promise.resolve[]>([])) )(); diff --git a/packages/web/src/components/Tx/TxFields.tsx b/packages/web/src/components/Tx/TxFields.tsx index ffe5c60b..001f4196 100644 --- a/packages/web/src/components/Tx/TxFields.tsx +++ b/packages/web/src/components/Tx/TxFields.tsx @@ -24,7 +24,7 @@ import { } from "react-bootstrap"; import { usePartialChange } from "../../hooks/formData"; import { useFormatters } from "../../hooks/prefs"; -import { fxRate, quote } from "../../services/ticker"; +import { ticker } from "../../services/ticker"; import { createDialog } from "../../util/modal"; import type { PropsOf } from "../../util/props"; import { DatePicker } from "../Form/DatePicker"; @@ -68,14 +68,14 @@ export const TxFields: React.FC = ({ const getRate = (date: Date) => pipe( - fxRate(asset.base_ccy, assetCcy, date), + ticker.fx(asset.base_ccy, assetCcy, date), TE.map((fx) => fx.rate), TE.getOrElse(() => () => Promise.resolve(asset.base.fxRate)) )(); const getQuote = (date: Date) => pipe( - quote(asset.ticker, date), + ticker.quote(asset.ticker, date), TE.map(({ price }) => price), TE.getOrElse( () => () => Promise.resolve(asset.meta.regularMarketPrice) diff --git a/packages/web/src/services/csv.ts b/packages/web/src/services/csv.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/web/src/services/portfolios.ts b/packages/web/src/services/portfolios.ts index 2b7a1cba..dd05481d 100644 --- a/packages/web/src/services/portfolios.ts +++ b/packages/web/src/services/portfolios.ts @@ -3,7 +3,7 @@ import { type Action, type EnrichedPortfolio, type Id, - type PostPortfolio, + type PostPortfolio } from "@darkruby/assets-core"; import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; import * as A from "fp-ts/lib/Array"; @@ -11,19 +11,14 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getPortfolio = ( - pid: number, - range?: ChartRange -): Action => { +const get = (pid: number, range?: ChartRange): Action => { return pipe( apiFromToken, TE.chain(({ portfolio }) => portfolio.get(pid, range)) ); }; -export const getPortfolios = ( - range?: ChartRange -): Action => { +const getMany = (range?: ChartRange): Action => { return pipe( apiFromToken, TE.chain(({ portfolio: p }) => p.getMany(range)), @@ -31,28 +26,31 @@ export const getPortfolios = ( ); }; -export const updatePortfolio = ( - pid: number, - p: PostPortfolio -): Action => { +const update = (pid: number, p: PostPortfolio): Action => { return pipe( apiFromToken, TE.chain(({ portfolio }) => portfolio.update(pid, p)) ); }; -export const createPortfolio = ( - p: PostPortfolio -): Action => { +const create = (p: PostPortfolio): Action => { return pipe( apiFromToken, TE.chain(({ portfolio }) => portfolio.create(p)) ); }; -export const deletePortfolio = (portfolioId: number): Action => { +const delete1 = (portfolioId: number): Action => { return pipe( apiFromToken, TE.chain(({ portfolio }) => portfolio.delete(portfolioId)) ); }; + +export const portfolios = { + get, + getMany, + create, + update, + delete: delete1 +}; diff --git a/packages/web/src/services/prefs.ts b/packages/web/src/services/prefs.ts index b27fcaf9..9e22399d 100644 --- a/packages/web/src/services/prefs.ts +++ b/packages/web/src/services/prefs.ts @@ -3,16 +3,21 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getPrefs = (): Action => { +const get = (): Action => { return pipe( apiFromToken, TE.chain(({ prefs }) => prefs.get()) ); }; -export const updatePrefs = (p: Prefs): Action => { +const update = (p: Prefs): Action => { return pipe( apiFromToken, TE.chain(({ prefs }) => prefs.update(p)) ); }; + +export const prefs = { + get, + update +}; diff --git a/packages/web/src/services/profile.ts b/packages/web/src/services/profile.ts index ec9dd266..e678c949 100644 --- a/packages/web/src/services/profile.ts +++ b/packages/web/src/services/profile.ts @@ -3,36 +3,43 @@ import type { GetUser, Id, PasswordChange, - PostUser, + PostUser } from "@darkruby/assets-core"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getProfile = (): Action => { +const get = (): Action => { return pipe( apiFromToken, TE.chain(({ profile }) => profile.get()) ); }; -export const deleteProfile = (): Action => { +const delete1 = (): Action => { return pipe( apiFromToken, TE.chain(({ profile }) => profile.delete()) ); }; -export const updateProfile = (usr: PostUser): Action => { +const update = (usr: PostUser): Action => { return pipe( apiFromToken, TE.chain(({ profile }) => profile.update(usr)) ); }; -export const updatePassword = (c: PasswordChange): Action => { +const password = (c: PasswordChange): Action => { return pipe( apiFromToken, TE.chain(({ profile }) => profile.password(c)) ); }; + +export const profile = { + get, + update, + delete: delete1, + password +}; diff --git a/packages/web/src/services/summary.ts b/packages/web/src/services/summary.ts index 76ee37b4..393f6e67 100644 --- a/packages/web/src/services/summary.ts +++ b/packages/web/src/services/summary.ts @@ -4,9 +4,13 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getSummary = (range?: ChartRange): Action => { +const get = (range?: ChartRange): Action => { return pipe( apiFromToken, TE.chain(({ summary }) => summary.get(range)) ); }; + +export const summary = { + get +}; diff --git a/packages/web/src/services/ticker.ts b/packages/web/src/services/ticker.ts index c2d088b9..8642a222 100644 --- a/packages/web/src/services/ticker.ts +++ b/packages/web/src/services/ticker.ts @@ -11,14 +11,14 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const lookupTicker = (term: string): Action => { +const lookup = (term: string): Action => { return pipe( apiFromToken, TE.chain(({ yahoo }) => yahoo.lookupTicker(term)) ); }; -export const fxRate = ( +const fx = ( base: Ccy, ccy: string, date: Optional @@ -29,7 +29,7 @@ export const fxRate = ( ); }; -export const quote = ( +const quote = ( ticker: string, date: Optional ): Action => { @@ -38,3 +38,9 @@ export const quote = ( TE.chain(({ yahoo }) => yahoo.quote(ticker, date)) ); }; + +export const ticker = { + lookup, + fx, + quote +}; diff --git a/packages/web/src/services/txs.ts b/packages/web/src/services/txs.ts index 0b4cddfb..5a991c4a 100644 --- a/packages/web/src/services/txs.ts +++ b/packages/web/src/services/txs.ts @@ -14,21 +14,14 @@ import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { apiFromToken } from "./api"; -export const getTx = ( - pid: PortfolioId, - aid: AssetId, - tid: TxId -): Action => { +const get = (pid: PortfolioId, aid: AssetId, tid: TxId): Action => { return pipe( apiFromToken, TE.chain(({ tx }) => tx.get(pid, aid, tid)) ); }; -export const getTxs = ( - pid: PortfolioId, - aid: AssetId -): Action => { +const getMany = (pid: PortfolioId, aid: AssetId): Action => { return pipe( apiFromToken, TE.chain(({ tx }) => tx.getMany(pid, aid)), @@ -36,7 +29,7 @@ export const getTxs = ( ); }; -export const createTx = ( +const create = ( pid: PortfolioId, aid: AssetId, t: PostTx @@ -47,7 +40,7 @@ export const createTx = ( ); }; -export const updateTx = ( +const update = ( pid: PortfolioId, aid: AssetId, tid: TxId, @@ -59,28 +52,21 @@ export const updateTx = ( ); }; -export const deleteTx = ( - pid: PortfolioId, - aid: AssetId, - txId: TxId -): Action => { +const delete1 = (pid: PortfolioId, aid: AssetId, txId: TxId): Action => { return pipe( apiFromToken, TE.chain(({ tx }) => tx.delete(pid, aid, txId)) ); }; -export const deleteAllAssetTx = ( - pid: PortfolioId, - aid: AssetId -): Action => { +const deleteAllAsset = (pid: PortfolioId, aid: AssetId): Action => { return pipe( apiFromToken, TE.chain(({ tx }) => tx.deleteAllAsset(pid, aid)) ); }; -export const uploadTxs = ( +const upload = ( pid: PortfolioId, aid: AssetId, payload: PostTxsUpload @@ -91,3 +77,13 @@ export const uploadTxs = ( TE.map(A.sort(byDateDesc)) ); }; + +export const txs = { + get, + getMany, + create, + update, + delete: delete1, + deleteAllAsset, + upload +}; diff --git a/packages/web/src/stores/portfolio.ts b/packages/web/src/stores/portfolio.ts index 15e31922..f680421b 100644 --- a/packages/web/src/stores/portfolio.ts +++ b/packages/web/src/stores/portfolio.ts @@ -3,19 +3,14 @@ import type { EnrichedPortfolio, Identity, Nullable, - PostPortfolio, + PostPortfolio } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; import { pipe } from "fp-ts/lib/function"; import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; import * as TE from "fp-ts/lib/TaskEither"; -import { - createPortfolio, - deletePortfolio, - getPortfolio, - updatePortfolio, -} from "../services/portfolios"; +import { portfolios } from "../services/portfolios"; import { type StoreBase, createStoreBase } from "./base"; export type PortfolioStore = Identity< @@ -40,17 +35,17 @@ export const createPortfolioStore = (): PortfolioStore => { return { ...storeBase, load: (portfolioId: number, range?: ChartRange) => - storeBase.run(getPortfolio(portfolioId, range)), + storeBase.run(portfolios.get(portfolioId, range)), update: (pid: number, p: PostPortfolio) => - storeBase.run(updatePortfolio(pid, p)), - create: (p: PostPortfolio) => storeBase.run(createPortfolio(p)), + storeBase.run(portfolios.update(pid, p)), + create: (p: PostPortfolio) => storeBase.run(portfolios.create(p)), delete: (pid: number) => storeBase.run( pipe( - deletePortfolio(pid), + portfolios.delete(pid), TE.map(() => null) ) - ), + ) }; }; diff --git a/packages/web/src/stores/portfolios.ts b/packages/web/src/stores/portfolios.ts index e7a49b3d..4bf69af5 100644 --- a/packages/web/src/stores/portfolios.ts +++ b/packages/web/src/stores/portfolios.ts @@ -2,18 +2,13 @@ import type { ActionResult, EnrichedPortfolio, Identity, - PostPortfolio, + PostPortfolio } from "@darkruby/assets-core"; import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; import { signal } from "@preact/signals-react"; import * as TE from "fp-ts/lib/TaskEither"; import { pipe } from "fp-ts/lib/function"; -import { - createPortfolio, - deletePortfolio, - getPortfolios, - updatePortfolio, -} from "../services/portfolios"; +import { portfolios } from "../services/portfolios"; import { type StoreBase, createStoreBase } from "./base"; export type PortfoliosStore = Identity< @@ -34,27 +29,27 @@ export const createPortfoliosStore = (): PortfoliosStore => { return { ...storeBase, - load: (range?: ChartRange) => storeBase.run(getPortfolios(range)), + load: (range?: ChartRange) => storeBase.run(portfolios.getMany(range)), create: (p: PostPortfolio) => storeBase.run( pipe( - createPortfolio(p), - TE.chain(() => getPortfolios()) + portfolios.create(p), + TE.chain(() => portfolios.getMany()) ) ), update: (pid: number, p: PostPortfolio) => storeBase.run( pipe( - updatePortfolio(pid, p), - TE.chain(() => getPortfolios()) + portfolios.update(pid, p), + TE.chain(() => portfolios.getMany()) ) ), delete: (pid: number) => storeBase.run( pipe( - deletePortfolio(pid), - TE.chain(() => getPortfolios()) + portfolios.delete(pid), + TE.chain(() => portfolios.getMany()) ) - ), + ) }; }; diff --git a/packages/web/src/stores/prefs.ts b/packages/web/src/stores/prefs.ts index 1b69b658..3e3f68b1 100644 --- a/packages/web/src/stores/prefs.ts +++ b/packages/web/src/stores/prefs.ts @@ -2,10 +2,10 @@ import type { ActionResult, Identity, Nullable, - Prefs, + Prefs } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; -import { getPrefs, updatePrefs } from "../services/prefs"; +import { prefs } from "../services/prefs"; import { type StoreBase, createStoreBase } from "./base"; export type PrefsStore = Identity< @@ -21,7 +21,7 @@ export const createPrefsStore = (): PrefsStore => { return { ...storeBase, - load: () => storeBase.run(getPrefs()), - update: (p: Prefs) => storeBase.run(updatePrefs(p)), + load: () => storeBase.run(prefs.get()), + update: (p: Prefs) => storeBase.run(prefs.update(p)) }; }; diff --git a/packages/web/src/stores/profile.ts b/packages/web/src/stores/profile.ts index 1c946503..a2c4583a 100644 --- a/packages/web/src/stores/profile.ts +++ b/packages/web/src/stores/profile.ts @@ -4,17 +4,12 @@ import type { Identity, Nullable, PasswordChange, - PostUser, + PostUser } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; -import { - deleteProfile, - getProfile, - updatePassword, - updateProfile, -} from "../services/profile"; +import { profile } from "../services/profile"; import { type StoreBase, createStoreBase } from "./base"; export type ProfileStore = Identity< @@ -32,15 +27,15 @@ export const createProfileStore = (): ProfileStore => { return { ...storeBase, - load: () => storeBase.run(getProfile()), - update: (c: PostUser) => storeBase.run(updateProfile(c)), - password: (c: PasswordChange) => storeBase.run(updatePassword(c)), + load: () => storeBase.run(profile.get()), + update: (c: PostUser) => storeBase.run(profile.update(c)), + password: (c: PasswordChange) => storeBase.run(profile.password(c)), delete: () => storeBase.run( pipe( - deleteProfile(), + profile.delete(), TE.map(() => null) ) - ), + ) }; }; diff --git a/packages/web/src/stores/summary.ts b/packages/web/src/stores/summary.ts index 9782114b..1930e17e 100644 --- a/packages/web/src/stores/summary.ts +++ b/packages/web/src/stores/summary.ts @@ -7,7 +7,7 @@ import type { import { signal } from "@preact/signals-react"; import type { ChartRange } from "@darkruby/assets-core/src/decoders/yahoo/meta"; -import { getSummary } from "../services/summary"; +import { summary } from "../services/summary"; import { type StoreBase, createStoreBase } from "./base"; export type SummaryStore = Identity< @@ -22,6 +22,6 @@ export const createSummaryStore = (): SummaryStore => { return { ...storeBase, - load: (range?: ChartRange) => storeBase.run(getSummary(range)) + load: (range?: ChartRange) => storeBase.run(summary.get(range)) }; }; diff --git a/packages/web/src/stores/tx.ts b/packages/web/src/stores/tx.ts index 2d2337a2..d9ca2947 100644 --- a/packages/web/src/stores/tx.ts +++ b/packages/web/src/stores/tx.ts @@ -6,12 +6,12 @@ import type { Nullable, PortfolioId, PostTx, - TxId, + TxId } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; import * as TE from "fp-ts/lib/TaskEither"; import { pipe } from "fp-ts/lib/function"; -import { createTx, deleteTx, getTx, updateTx } from "../services/txs"; +import { txs } from "../services/txs"; import { type StoreBase, createStoreBase } from "./base"; export type TxStore = Identity< @@ -47,17 +47,17 @@ export const createTxStore = (): TxStore => { return { ...storeBase, load: (pid: PortfolioId, aid: AssetId, tid: TxId) => - storeBase.run(getTx(pid, aid, tid)), + storeBase.run(txs.get(pid, aid, tid)), create: (pid: PortfolioId, aid: AssetId, t: PostTx) => - storeBase.run(createTx(pid, aid, t)), + storeBase.run(txs.create(pid, aid, t)), update: (pid: PortfolioId, aid: AssetId, tid: TxId, t: PostTx) => - storeBase.run(updateTx(pid, aid, tid, t)), + storeBase.run(txs.update(pid, aid, tid, t)), delete: (pid: PortfolioId, aid: AssetId, tid: TxId) => storeBase.run( pipe( - deleteTx(pid, aid, tid), + txs.delete(pid, aid, tid), TE.map(() => null) ) - ), + ) }; }; diff --git a/packages/web/src/stores/txs.ts b/packages/web/src/stores/txs.ts index acdac5dc..b2d4d6ba 100644 --- a/packages/web/src/stores/txs.ts +++ b/packages/web/src/stores/txs.ts @@ -6,19 +6,12 @@ import type { PortfolioId, PostTx, PostTxsUpload, - TxId, + TxId } from "@darkruby/assets-core"; import { signal } from "@preact/signals-react"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; -import { - createTx, - deleteAllAssetTx, - deleteTx, - getTxs, - updateTx, - uploadTxs, -} from "../services/txs"; +import { txs } from "../services/txs"; import { type StoreBase, createStoreBase } from "./base"; export type TxsStore = Identity< @@ -58,36 +51,37 @@ export const createTxsStore = (): TxsStore => { return { ...storeBase, - load: (pid: PortfolioId, aid: AssetId) => storeBase.run(getTxs(pid, aid)), + load: (pid: PortfolioId, aid: AssetId) => + storeBase.run(txs.getMany(pid, aid)), create: (pid: PortfolioId, aid: AssetId, p: PostTx) => storeBase.run( pipe( - createTx(pid, aid, p), - TE.chain(() => getTxs(pid, aid)) + txs.create(pid, aid, p), + TE.chain(() => txs.getMany(pid, aid)) ) ), update: (pid: PortfolioId, aid: AssetId, tid: number, p: PostTx) => storeBase.run( pipe( - updateTx(pid, aid, tid, p), - TE.chain(() => getTxs(pid, aid)) + txs.update(pid, aid, tid, p), + TE.chain(() => txs.getMany(pid, aid)) ) ), delete: (pid: PortfolioId, aid: AssetId, tid: number) => storeBase.run( pipe( - deleteTx(pid, aid, tid), - TE.chain(() => getTxs(pid, aid)) + txs.delete(pid, aid, tid), + TE.chain(() => txs.getMany(pid, aid)) ) ), deleteAllAsset: (pid: PortfolioId, aid: AssetId) => storeBase.run( pipe( - deleteAllAssetTx(pid, aid), - TE.chain(() => getTxs(pid, aid)) + txs.deleteAllAsset(pid, aid), + TE.chain(() => txs.getMany(pid, aid)) ) ), upload: (pid: PortfolioId, aid: AssetId, data: PostTxsUpload) => - storeBase.run(uploadTxs(pid, aid, data)), + storeBase.run(txs.upload(pid, aid, data)) }; }; From ba8d53ffebcac616b1af51ac1fabb270ad262ae8 Mon Sep 17 00:00:00 2001 From: darkruby Date: Thu, 13 Aug 2026 22:35:18 +0100 Subject: [PATCH 4/7] admin: change user password --- packages/backend/src/handlers/index.ts | 1 + packages/backend/src/handlers/profile.ts | 6 +- packages/backend/src/handlers/user.ts | 13 ++- packages/backend/src/index.ts | 1 + packages/backend/src/services/index.ts | 12 ++- packages/backend/src/services/profile.ts | 121 +++++++++++++++++++++++ packages/backend/src/services/user.ts | 73 +------------- 7 files changed, 146 insertions(+), 81 deletions(-) create mode 100644 packages/backend/src/services/profile.ts diff --git a/packages/backend/src/handlers/index.ts b/packages/backend/src/handlers/index.ts index d5c3aabf..082f8f18 100644 --- a/packages/backend/src/handlers/index.ts +++ b/packages/backend/src/handlers/index.ts @@ -46,6 +46,7 @@ export const createHandlers = ( getMany: pipe(user.getUsers, expressify), create: pipe(user.createUser, expressify), update: pipe(user.updateUser, expressify), + password: pipe(user.updatePassword, expressify), delete: pipe(user.deleteUser, expressify) }, prefs: { diff --git a/packages/backend/src/handlers/profile.ts b/packages/backend/src/handlers/profile.ts index b685a427..e4e15b2c 100644 --- a/packages/backend/src/handlers/profile.ts +++ b/packages/backend/src/handlers/profile.ts @@ -23,9 +23,7 @@ export const updateProfile: HandlerTask = ({ TE.Do, TE.bind("userId", () => service.auth.requireUserId(res)), mapWebError, - TE.chain(({ userId }) => - service.user.updateOwnProfileOnly(userId, req.body) - ) + TE.chain(({ userId }) => service.profile.updateOwn(userId, req.body)) ); export const updatePassword: HandlerTask = ({ @@ -37,7 +35,7 @@ export const updatePassword: HandlerTask = ({ TE.bind("profile", () => service.auth.requireProfile(res)), mapWebError, TE.chain(({ profile }) => - service.user.updateOwnPasswordOnly(profile, req.body) + service.profile.updateOwnPassword(profile, req.body) ) ); diff --git a/packages/backend/src/handlers/user.ts b/packages/backend/src/handlers/user.ts index 7daef964..6c0c2562 100644 --- a/packages/backend/src/handlers/user.ts +++ b/packages/backend/src/handlers/user.ts @@ -56,5 +56,16 @@ export const updateUser: HandlerTask = ({ service.auth.requireAdminProfile(res), TE.chain(() => urlUserId(req)), mapWebError, - TE.chain((id) => service.user.updateProfileOnly(id, req.body)) + TE.chain((id) => service.profile.update(id, req.body)) + ); + +export const updatePassword: HandlerTask = ({ + params: [req, res], + context: { service } +}) => + pipe( + service.auth.requireAdminProfile(res), + TE.chain(() => urlUserId(req)), + mapWebError, + TE.chain((userId) => service.profile.updatePassword(userId, req.body)) ); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0b1da8e3..9107053c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -93,6 +93,7 @@ const server = ({ port, app }: Config, ctx: Context): Action => { user.get("/", handlers.user.getMany); user.get("/:user_id", handlers.user.get); user.put("/:user_id", handlers.user.update); + user.patch("/:user_id", handlers.user.password); user.delete("/:user_id", handlers.user.delete); api.use("/users", user); diff --git a/packages/backend/src/services/index.ts b/packages/backend/src/services/index.ts index e8fc340f..64c49f05 100644 --- a/packages/backend/src/services/index.ts +++ b/packages/backend/src/services/index.ts @@ -5,6 +5,7 @@ import * as asset from "./asset"; import * as auth from "./auth"; import * as portfolio from "./portfolio"; import * as prefs from "./prefs"; +import * as profile from "./profile"; import * as tx from "./tx"; import * as user from "./user"; @@ -24,14 +25,17 @@ export const createWebService = ( requireProfile: auth.requireProfile, requireAdminProfile: auth.requireAdminProfile }, + profile: { + update: profile.updateProfile(repo), + updateOwn: profile.updateOwnProfile(repo), + updatePassword: profile.updatePassword(repo), + updateOwnPassword: profile.updateOwnPassword(repo) + }, user: { get: user.getUser(repo), getMany: user.getUsers(repo), create: user.createUser(repo), - delete: user.deleteUser(repo), - updateProfileOnly: user.updateProfileOnly(repo), - updateOwnProfileOnly: user.updateOwnProfileOnly(repo), - updateOwnPasswordOnly: user.updateOwnPasswordOnly(repo) + delete: user.deleteUser(repo) }, assets: { get: asset.getAsset(repo, enricher.asset), diff --git a/packages/backend/src/services/profile.ts b/packages/backend/src/services/profile.ts new file mode 100644 index 00000000..6f9e0ae1 --- /dev/null +++ b/packages/backend/src/services/profile.ts @@ -0,0 +1,121 @@ +import { + AppErrorType, + handleError, + PasswordChangeDecoder, + PostUserDecoder, + validationError, + type GetUser, + type Profile, + type UserId +} from "@darkruby/assets-core"; +import { liftTE } from "@darkruby/assets-core/src/decoders/util"; +import { pipe } from "fp-ts/lib/function"; +import * as TE from "fp-ts/lib/TaskEither"; +import { mapWebError } from "../domain/error"; +import { notFound, type WebAction } from "../fp-express"; +import type { Repository } from "../repository"; +import { verifyPassword } from "./auth"; +import { toRawInUser } from "./user"; + +export const updateProfile = + (repo: Repository) => + (id: UserId, payload: unknown): WebAction => { + return pipe( + pipe(payload, liftTE(PostUserDecoder)), + TE.chain((profile) => repo.user.updateProfileOnly(id, profile)), + TE.chain(() => repo.user.get(id)), + mapWebError, + TE.filterOrElse((u): u is GetUser => Boolean(u), notFound) + ); + }; + +export const updateOwnProfile = + (repo: Repository) => + (userId: UserId, payload: unknown): WebAction => + pipe( + TE.Do, + TE.bind("ownProfile", () => + pipe( + repo.user.get(userId), + TE.filterOrElse( + (u): u is GetUser => Boolean(u), + handleError("Profile not found", AppErrorType.Validation) + ) + ) + ), + TE.bind("profile", () => pipe(payload, liftTE(PostUserDecoder))), + mapWebError, + TE.chain(({ profile, ownProfile }) => + updateProfile(repo)(userId, { + ...ownProfile, + username: profile.username + }) + ) + ); + +export const updateOwnPassword = + (repo: Repository) => + (profile: Profile, payload: unknown): WebAction => + pipe( + TE.Do, + // TE.tap(() => repo.user.resetAttempts(profile.username)), // likely not needed + TE.bind("user", () => repo.user.loginAttempt(profile.username)), + TE.bind("passwordChange", () => + pipe(payload, liftTE(PasswordChangeDecoder)) + ), + TE.tap(({ user, passwordChange }) => + pipe( + verifyPassword(user.phash, passwordChange.oldPassword), + TE.mapLeft(() => validationError("Wrong old password")) + ) + ), + TE.chain(({ user, passwordChange }) => + toRawInUser({ + username: user.username, + password: passwordChange.newPassword, + admin: user.admin, + locked: user.locked + }) + ), + TE.chain((user) => repo.user.update(profile.id, user)), + mapWebError + ); + +export const updatePassword = + (repo: Repository) => + (userId: UserId, payload: unknown): WebAction => + pipe( + TE.Do, + TE.bind("profile", () => + pipe( + repo.user.get(userId), + TE.filterOrElse( + (u): u is GetUser => Boolean(u), + handleError("Profile not found", AppErrorType.Validation) + ) + ) + ), + TE.bind("user", ({ profile }) => + repo.user.loginAttempt(profile.username) + ), + // TE.tap(({ user }) => repo.user.resetAttempts(user!.username)), + TE.bind("passwordChange", () => + pipe(payload, liftTE(PasswordChangeDecoder)) + ), + // TE.tap(({ user, passwordChange }) => + // pipe( + // verifyPassword(user.phash, passwordChange.oldPassword), + // TE.mapLeft(() => validationError("Wrong old password")) + // ) + // ), + TE.chain(({ user, passwordChange }) => + toRawInUser({ + username: user.username, + password: passwordChange.newPassword, + admin: user.admin, + locked: user.locked + }) + ), + TE.chain((rawUser) => repo.user.update(userId, rawUser)), + mapWebError + ); diff --git a/packages/backend/src/services/user.ts b/packages/backend/src/services/user.ts index c6faf158..f8063918 100644 --- a/packages/backend/src/services/user.ts +++ b/packages/backend/src/services/user.ts @@ -1,19 +1,13 @@ import { - AppErrorType, GetUserDecoder, GetUsersDecoder, - handleError, NewUserDecoder, - PasswordChangeDecoder, - PostUserDecoder, RawInUserDecoder, - validationError, type Action, type GetUser, type Id, type NewUser, type Optional, - type Profile, type RawInUser, type UserId } from "@darkruby/assets-core"; @@ -22,9 +16,8 @@ import { password as Pwd } from "bun"; import { pipe } from "fp-ts/lib/function"; import * as TE from "fp-ts/lib/TaskEither"; import { mapWebError } from "../domain/error"; -import { notFound, type WebAction } from "../fp-express"; +import { type WebAction } from "../fp-express"; import type { Repository } from "../repository"; -import { verifyPassword } from "./auth"; const parseNewUser = liftTE(NewUserDecoder); @@ -92,67 +85,3 @@ export const deleteUser = mapWebError ); }; - -export const updateProfileOnly = - (repo: Repository) => - (id: UserId, body: unknown): WebAction => { - return pipe( - pipe(body, liftTE(PostUserDecoder)), - TE.chain((profile) => repo.user.updateProfileOnly(id, profile)), - TE.chain(() => repo.user.get(id)), - mapWebError, - TE.filterOrElse((u): u is GetUser => Boolean(u), notFound) - ); - }; - -export const updateOwnProfileOnly = - (repo: Repository) => - (userId: UserId, payload: unknown): WebAction => - pipe( - TE.Do, - TE.bind("ownProfile", () => - pipe( - repo.user.get(userId), - TE.filterOrElse( - (u): u is GetUser => Boolean(u), - handleError("Profile not found", AppErrorType.Validation) - ) - ) - ), - TE.bind("profile", () => pipe(payload, liftTE(PostUserDecoder))), - mapWebError, - TE.chain(({ profile, ownProfile }) => - updateProfileOnly(repo)(userId, { - ...ownProfile, - username: profile.username - }) - ) - ); - -export const updateOwnPasswordOnly = - (repo: Repository) => - (profile: Profile, payload: unknown): WebAction => - pipe( - TE.Do, - TE.tap(() => repo.user.resetAttempts(profile.username)), - TE.bind("user", () => repo.user.loginAttempt(profile.username)), - TE.bind("passwordChange", () => - pipe(payload, liftTE(PasswordChangeDecoder)) - ), - TE.tap(({ user, passwordChange }) => - pipe( - verifyPassword(user.phash, passwordChange.oldPassword), - TE.mapLeft(() => validationError("Wrong old password")) - ) - ), - TE.chain(({ user, passwordChange }) => - toRawInUser({ - username: user.username, - password: passwordChange.newPassword, - admin: user.admin, - locked: user.locked - }) - ), - TE.chain((user) => repo.user.update(profile.id, user)), - mapWebError - ); From 02f9b52b6dfa8b8c5c1d45586a8e44b675a93a4b Mon Sep 17 00:00:00 2001 From: darkruby Date: Fri, 14 Aug 2026 21:24:22 +0100 Subject: [PATCH 5/7] password change unit test --- packages/backend/test/helper.ts | 10 ++++++++ packages/backend/test/profile.spec.ts | 22 ++++++++++------- packages/backend/test/user.spec.ts | 24 ++++++++++++++++++- .../src/components/Profile/PasswordChange.tsx | 14 +++++++---- packages/web/src/components/Users/Menu.tsx | 2 +- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/backend/test/helper.ts b/packages/backend/test/helper.ts index 677eb80e..b4cce10d 100644 --- a/packages/backend/test/helper.ts +++ b/packages/backend/test/helper.ts @@ -6,6 +6,7 @@ import { type Api, type Credentials, type NewUser, + type PasswordChange, type PostAsset, type PostPortfolio, type PostTx, @@ -32,6 +33,15 @@ export const fakeNewUser = (admin = false): NewUser => ({ locked: false }); +export const fakeNePassword = (oldPassword: string = "-"): PasswordChange => { + const newPassword = faker.internet.password(); + return { + oldPassword, + newPassword, + repeat: newPassword + }; +}; + export const fakePortfolio = (): PostPortfolio => ({ name: faker.lorem.slug(2), description: faker.lorem.slug(2) diff --git a/packages/backend/test/profile.spec.ts b/packages/backend/test/profile.spec.ts index 579a3c7d..92b05a77 100644 --- a/packages/backend/test/profile.spec.ts +++ b/packages/backend/test/profile.spec.ts @@ -2,7 +2,13 @@ import { run, type UserId } from "@darkruby/assets-core"; import { afterAll, beforeAll, expect, test } from "bun:test"; import faker from "faker"; import * as E from "fp-ts/Either"; -import { defaultApi, fakeNewUser, nonAdminApi, type TestApi } from "./helper"; +import { + defaultApi, + fakeNePassword, + fakeNewUser, + nonAdminApi, + type TestApi +} from "./helper"; let api: TestApi; beforeAll(async () => { @@ -36,15 +42,13 @@ test("Update own password", async () => { // login as that user const api = await run(defaultApi(user1)); // generate new password and change it - const { password: newPassword } = fakeNewUser(); - const changePassword = { - oldPassword: user1.password, - newPassword, - repeat: newPassword, - }; - await run(api.profile.password(changePassword)); + const passwordChange = fakeNePassword(user1.password); + + await run(api.profile.password(passwordChange)); // login with new password - const api1 = await run(defaultApi({ ...user1, password: newPassword })); + const api1 = await run( + defaultApi({ ...user1, password: passwordChange.newPassword }) + ); expect(api1).toBeTruthy(); }); diff --git a/packages/backend/test/user.spec.ts b/packages/backend/test/user.spec.ts index 8604ed7b..b9447966 100644 --- a/packages/backend/test/user.spec.ts +++ b/packages/backend/test/user.spec.ts @@ -2,7 +2,12 @@ import { run, type UserId } from "@darkruby/assets-core"; import { afterAll, beforeAll, expect, test } from "bun:test"; import faker from "faker"; import * as E from "fp-ts/Either"; -import { defaultApi, fakeNewUser, type TestApi } from "./helper"; +import { + defaultApi, + fakeNePassword, + fakeNewUser, + type TestApi +} from "./helper"; let api: TestApi; beforeAll(async () => { @@ -58,3 +63,20 @@ test("Create user, login and check profile & prefs", async () => { expect(username).toBe(creds.username); expect(prefs.base_ccy).toBe("USD"); }); + +test("Create user, change their password, then login and get prefs", async () => { + const creds = fakeNewUser(); + const user = await run(api.user.create(creds)); + + const passwordChange = fakeNePassword(); + await run(api.user.password(user.id, passwordChange)); + + const userApi = await run( + defaultApi({ + username: user.username, + password: passwordChange.newPassword + }) + ); + const profile = await run(userApi.profile.get()); + expect(user.username).toBe(profile.username); +}); diff --git a/packages/web/src/components/Profile/PasswordChange.tsx b/packages/web/src/components/Profile/PasswordChange.tsx index b4f0bf0b..9828e5e4 100644 --- a/packages/web/src/components/Profile/PasswordChange.tsx +++ b/packages/web/src/components/Profile/PasswordChange.tsx @@ -1,7 +1,7 @@ import { defaultPasswordChange, passwordChangeValidator, - type PasswordChange as PasswordChangeData, + type PasswordChange as PasswordChangeData } from "@darkruby/assets-core"; import { pipe } from "fp-ts/lib/function"; import * as React from "react"; @@ -15,17 +15,20 @@ import { createForm, type FieldsProps } from "../Form/Form"; import { PasswordEdit } from "../Form/Password"; import { createModal } from "../Modals/Modal"; -type PasswordChangeFieldsProps = FieldsProps; +type PasswordChangeFieldsProps = FieldsProps & { + ignoreOldPassword?: boolean; +}; export const PasswordChangeFields: React.FC = ({ data, onChange, disabled, + ignoreOldPassword = false }: PasswordChangeFieldsProps) => { const setField = usePartialChange(data, onChange); return ( - +