feat: update client data

This commit is contained in:
2026-01-06 11:08:55 +01:00
parent 37164b560f
commit 704d0d1821
50 changed files with 1215 additions and 471 deletions

11
.vscode/settings.json vendored
View File

@@ -8,6 +8,15 @@
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.exclude": {
"**/.git": true,
"**/.svn": true,
@@ -15,5 +24,5 @@
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
}
}

View File

@@ -6,6 +6,6 @@
"dependencies": {
"@module/payment": "workspace:*",
"@platform/config": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -34,7 +34,7 @@
"@tailwindcss/vite": "4.1.17",
"@tanstack/react-router": "1.139.9",
"@tanstack/react-table": "8.21.3",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
"@valkyr/db": "npm:@jsr/valkyr__db@3.0.1",
"@zitadel/react": "1.1.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -48,10 +48,12 @@
"tailwind-merge": "3.4.0",
"tailwindcss": "4.1.17",
"vaul": "1.1.2",
"zod": "4.1.13"
"zod": "4.3.5"
},
"devDependencies": {
"@eslint/js": "9.39.1",
"@tanstack/react-router-devtools": "1.144.0",
"@tanstack/router-plugin": "1.145.2",
"@types/node": "24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",

View File

@@ -1,26 +0,0 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { zitadel } from "../../services/zitadel.ts";
export function CallbackView() {
const navigate = useNavigate();
useEffect(() => {
async function handleCallback() {
try {
const user = await zitadel.userManager.signinRedirectCallback();
if (user) {
navigate({ to: "/", replace: true });
} else {
navigate({ to: "/", replace: true });
}
} catch (error) {
console.error("Callback error", error);
navigate({ to: "/", replace: true });
}
}
handleCallback();
}, [navigate]);
return null;
}

View File

@@ -1,61 +0,0 @@
import { GalleryVerticalEnd } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldSeparator } from "@/components/ui/field";
import { cn } from "@/lib/utils";
import { zitadel } from "@/services/zitadel.ts";
export function LoginForm({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<form
onSubmit={(e) => {
e.preventDefault();
zitadel.authorize();
}}
>
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
<a href="#" className="flex flex-col items-center gap-2 font-medium">
<div className="flex size-8 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-6" />
</div>
<span className="sr-only">Valkyr Sandbox</span>
</a>
<h1 className="text-xl font-bold">Welcome to Valkyr Sandbox</h1>
<FieldDescription>
Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription>
</div>
<Field>
<Button type="submit">Login with Zitadel</Button>
</Field>
<FieldSeparator>Or</FieldSeparator>
<Field className="grid gap-4 sm:grid-cols-2">
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Continue with Apple
</Button>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Continue with Google
</Button>
</Field>
</FieldGroup>
</form>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
</FieldDescription>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { LoginForm } from "./components/login-form.tsx";
export function LoginView() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
import { Controller } from "@/lib/controller.tsx";
export class DashboardController extends Controller {}

View File

@@ -1,7 +0,0 @@
import { makeControllerComponent } from "@/lib/controller.tsx";
import { DashboardController } from "./dashboard.controller.ts";
export const DashboardView = makeControllerComponent(DashboardController, () => {
return <div>Dashboard</div>;
});

View File

@@ -1,7 +1,7 @@
import { GalleryVerticalEnd } from "lucide-react";
import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts";
import { auth } from "@/services/auth.ts";
type Tenant = {
name: string;
@@ -13,7 +13,7 @@ export class AppSiderbarController extends Controller<{
tenant: Tenant;
}> {
async onInit() {
const user = await User.resolve();
const user = auth.user;
if (user === undefined) {
return {
tenant: {

View File

@@ -12,20 +12,25 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LoadingSwap } from "@/components/ui/loading-swap";
import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx";
import { makeControllerComponent } from "../../lib/controller.tsx";
import { CreateBeneficiaryController } from "./create-beneficiary.controller.ts";
import { FieldDescription, FieldGroup, FieldLabel } from "../ui/field.tsx";
import { CreateBeneficiaryController } from "./create.controller.ts";
export const DialogCreateBeneficiary = makeControllerComponent(
CreateBeneficiaryController,
({ title, setLabel, submit, isSubmitting, ...props }) => {
({ title, label, setLabel, submit, isSubmitting, ...props }) => {
const labelId = useId();
return (
<Dialog>
<form>
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<DialogTrigger asChild>
<SidebarMenuSubButton className="cursor-pointer bg-secondary hover:bg-secondary/70">
<props.icon /> <span>{title}</span>
@@ -33,20 +38,25 @@ export const DialogCreateBeneficiary = makeControllerComponent(
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create beneficiary</DialogTitle>
<DialogTitle>Create Beneficiary</DialogTitle>
<DialogDescription>Create a payment ledger and its supported currencies</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor={labelId}>Label</Label>
<Input id={labelId} name="label" onChange={({ target: { value } }) => setLabel(value)} />
</div>
</div>
<FieldGroup>
<FieldLabel htmlFor={labelId}>Label</FieldLabel>
<Input
id={labelId}
name="label"
value={label}
onChange={({ target: { value } }) => setLabel(value)}
required
/>
<FieldDescription>Enter the identifying label for the Beneficiary</FieldDescription>
</FieldGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" onClick={submit}>
<Button type="submit">
<LoadingSwap isLoading={isSubmitting}>Create</LoadingSwap>
</Button>
</DialogFooter>

View File

@@ -3,32 +3,32 @@ import type { LucideIcon } from "lucide-react";
import { payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx";
import { api, getSuccessResponse } from "@/services/api.ts";
import { User } from "@/services/user.ts";
import { auth } from "@/services/auth.ts";
export class CreateBeneficiaryController extends Controller<
{
isSubmitting: boolean;
label: string;
},
{ title: string; icon: LucideIcon }
> {
#label?: string;
async onInit() {
return {
isSubmitting: false,
label: "",
};
}
setLabel(label: string) {
this.#label = label;
this.setState("label", label);
}
async submit() {
this.setState("isSubmitting", true);
const res = await api.payment.benficiaries.create({
body: {
tenantId: await User.getTenantId(),
label: this.#label,
tenantId: auth.user.tenant.id,
label: this.state.label,
},
});
if ("data" in res) {
@@ -36,6 +36,9 @@ export class CreateBeneficiaryController extends Controller<
.collection("beneficiary")
.insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } })));
}
this.setState("isSubmitting", false);
this.setState({
isSubmitting: false,
label: "",
});
}
}

View File

@@ -0,0 +1,15 @@
import { makeControllerComponent } from "@/lib/controller.tsx";
import { ReadBeneficiaryController } from "./read.controller.ts";
export const BeneficiaryComponent = makeControllerComponent(ReadBeneficiaryController, ({ beneficiary }) => {
if (beneficiary === undefined) {
return (
<div className="w-full flex flex-col pt-20 items-center justify-center text-center">
<h1 className="text-2xl font-semibold tracking-tight">Beneficiary Not Found</h1>
<p className="text-muted-foreground mt-2 max-w-sm">Beneficiary with id "xxx" cannot be found.</p>
</div>
);
}
return <pre>{JSON.stringify(beneficiary, null, 2)}</pre>;
});

View File

@@ -0,0 +1,37 @@
import type { Beneficiary } from "@module/payment/client";
import { payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx";
import { auth } from "@/services/auth.ts";
export class ReadBeneficiaryController extends Controller<
{
beneficiary?: Beneficiary;
},
{
id: string;
}
> {
#subscription?: any;
async onInit() {
await this.#subscribe(this.props.id);
}
async onResolve() {
await this.#subscribe(this.props.id);
}
async onDestroy(): Promise<void> {
this.#subscription?.unsubscribe();
}
async #subscribe(_id: string) {
this.#subscription?.unsubscribe();
this.#subscription = await payment
.collection("beneficiary")
.subscribe({ _id, tenantId: auth.user.tenant.id }, { limit: 1 }, (beneficiary) => {
this.setState("beneficiary", beneficiary);
});
}
}

View File

@@ -2,11 +2,10 @@ import type { Beneficiary } from "@module/payment/client";
import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react";
import type { FunctionComponent } from "react";
import { DialogCreateBeneficiary } from "@/components/beneficiary/create.component.tsx";
import { loadBeneficiaries, payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts";
import { DialogCreateBeneficiary } from "./payment/create-beneficiary.tsx";
import { auth } from "@/services/auth.ts";
export class NavPaymentController extends Controller<{
items: MenuItem[];
@@ -19,7 +18,7 @@ export class NavPaymentController extends Controller<{
items: [
{
title: "Dashboard",
url: "/payment",
url: "/",
icon: SquareTerminal,
},
],
@@ -31,7 +30,7 @@ export class NavPaymentController extends Controller<{
this.#subscriptions.push(
await payment
.collection("beneficiary")
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (beneficiaries) => {
.subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (beneficiaries) => {
this.setState("items", this.#getMenuItems(beneficiaries));
}),
);
@@ -41,7 +40,7 @@ export class NavPaymentController extends Controller<{
return [
{
title: "Dashboard",
url: "/payment",
url: "/",
icon: SquareTerminal,
},
{
@@ -57,7 +56,7 @@ export class NavPaymentController extends Controller<{
},
...beneficiaries.map((beneficiary) => ({
title: beneficiary.label ?? "Unlabeled",
url: `/payment/${beneficiary._id}`,
url: `/beneficiaries/${beneficiary._id}`,
})),
],
},

View File

@@ -39,7 +39,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
"group/field-group @container/field-group flex w-full flex-col gap-3 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className,
)}
{...props}

View File

@@ -2,9 +2,9 @@ import type { Beneficiary } from "@module/payment/client";
import { loadBeneficiaries, payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts";
import { auth } from "@/services/auth.ts";
export class PaymentDashboardController extends Controller<{
export class DashboardController extends Controller<{
beneficiaries: Beneficiary[];
}> {
#subscriptions: any[] = [];
@@ -28,7 +28,7 @@ export class PaymentDashboardController extends Controller<{
this.#subscriptions.push(
await payment
.collection("beneficiary")
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (documents) => {
.subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (documents) => {
this.setState("beneficiaries", documents);
}),
);

View File

@@ -1,43 +1,89 @@
import type { Account, Beneficiary, Ledger, Transaction, Wallet } from "@module/payment/client";
import { IndexedDatabase } from "@valkyr/db";
import {
AccountSchema,
BeneficiarySchema,
LedgerSchema,
TransactionSchema,
WalletSchema,
} from "@module/payment/client";
import { IndexedDB } from "@valkyr/db";
import { api } from "@/services/api.ts";
export const payment = new IndexedDatabase<{
account: Account;
beneficiary: Beneficiary;
ledger: Ledger;
transaction: Transaction;
wallet: Wallet;
}>({
export const payment = new IndexedDB({
name: "valkyr-sandbox:payment",
registrars: [
{
name: "account",
indexes: [["_id", { unique: true }], ["walletId"]],
schema: AccountSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "walletId",
kind: "shared",
},
],
},
{
name: "beneficiary",
indexes: [["_id", { unique: true }], ["tenantId"]],
schema: BeneficiarySchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "tenantId",
kind: "shared",
},
],
},
{
name: "ledger",
indexes: [["_id", { unique: true }], ["beneficiaryId"]],
schema: LedgerSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "beneficiaryId",
kind: "shared",
},
],
},
{
name: "transaction",
indexes: [["_id", { unique: true }]],
schema: TransactionSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
],
},
{
name: "wallet",
indexes: [["_id", { unique: true }], ["ledgerId"]],
schema: WalletSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "ledgerId",
kind: "shared",
},
],
},
],
] as const,
});
export async function loadBeneficiaries(): Promise<void> {
const result = await api.payment.benficiaries.list();
if ("data" in result) {
payment.collection("beneficiary").insertMany(result.data);
payment.collection("beneficiary").insert(result.data);
}
}

View File

@@ -50,7 +50,7 @@ export abstract class Controller<
return;
}
const state = await this.onInit();
if (this.#destroyed === false) {
if (this.#destroyed === false && state !== undefined) {
this.setState(state);
}
this.#initiated = true;
@@ -64,10 +64,7 @@ export abstract class Controller<
if (this.onResolve === undefined) {
return;
}
const state: Partial<TState> = await this.onResolve();
if (this.#destroyed === false) {
this.setState({ ...this.state, ...state });
}
await this.onResolve();
}
async $destroy(): Promise<void> {
@@ -84,16 +81,12 @@ export abstract class Controller<
/**
* Called every time props change (including first mount).
*/
async onInit(): Promise<Partial<TState>> {
return {};
}
async onInit(): Promise<Partial<TState> | void> {}
/**
* Called every time props change (including first mount).
*/
async onResolve(): Promise<Partial<TState>> {
return {};
}
async onResolve(): Promise<void> {}
/**
* Called when the controller is destroyed.

28
app/src/lib/router.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createRouter } from "@tanstack/react-router";
import { auth } from "@/services/auth.ts";
import { routeTree } from "../routeTree.gen.ts";
export const router = createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
context: {
auth,
},
});
export function getRouteParam(key: string): string {
return getRouteParams()[key] ?? "";
}
export function getRouteParams(): Record<string, string> {
return router.state.matches.at(-1)?.params ?? {};
}
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

View File

@@ -1,57 +0,0 @@
import { createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
import { AppView } from "@/app/app.view.tsx";
import { CallbackView } from "@/app/auth/callback.view.tsx";
import { LoginView } from "@/app/auth/login.view.tsx";
import { DashboardView } from "@/app/dashboard/dashboard.view.tsx";
import { zitadel } from "@/services/zitadel.ts";
import { PaymentDashboardView } from "../app/payment/dashboard/dashboard.view.tsx";
const root = createRootRoute();
const callback = createRoute({
getParentRoute: () => root,
path: "/auth/callback",
component: CallbackView,
});
const login = createRoute({
getParentRoute: () => root,
path: "/login",
component: LoginView,
});
const app = createRoute({
id: "app",
getParentRoute: () => root,
beforeLoad: async () => {
const user = await zitadel.userManager.getUser();
if (user === null) {
throw redirect({ to: "/login" });
}
if (user.expired === true) {
throw redirect({ to: "/login" });
}
},
component: AppView,
});
const dashboard = createRoute({
getParentRoute: () => app,
path: "/",
component: DashboardView,
});
const payment = [
createRoute({
getParentRoute: () => app,
path: "/payment",
component: PaymentDashboardView,
}),
];
root.addChildren([app, login, callback]);
app.addChildren([dashboard, ...payment]);
export const router = createRouter({ routeTree: root });

View File

@@ -3,14 +3,9 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@/components/theme-provider.tsx";
import { router } from "@/lib/router.tsx";
import "./index.css";
import { router } from "@/lib/router";
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
import "./index.css";
const rootElement = document.getElementById("root");
if (rootElement === null) {

126
app/src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,126 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as CallbackRouteImport } from './routes/callback'
import { Route as AuthRouteRouteImport } from './routes/_auth/route'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthBeneficiariesBeneficiaryIdRouteImport } from './routes/_auth/beneficiaries/$beneficiaryId'
const CallbackRoute = CallbackRouteImport.update({
id: '/callback',
path: '/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRouteRoute = AuthRouteRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthRouteRoute,
} as any)
const AuthBeneficiariesBeneficiaryIdRoute =
AuthBeneficiariesBeneficiaryIdRouteImport.update({
id: '/beneficiaries/$beneficiaryId',
path: '/beneficiaries/$beneficiaryId',
getParentRoute: () => AuthRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/callback': typeof CallbackRoute
'/': typeof AuthIndexRoute
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRoutesByTo {
'/callback': typeof CallbackRoute
'/': typeof AuthIndexRoute
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteRouteWithChildren
'/callback': typeof CallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
fileRoutesByTo: FileRoutesByTo
to: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
id:
| '__root__'
| '/_auth'
| '/callback'
| '/_auth/'
| '/_auth/beneficiaries/$beneficiaryId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthRouteRoute: typeof AuthRouteRouteWithChildren
CallbackRoute: typeof CallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/callback': {
id: '/callback'
path: '/callback'
fullPath: '/callback'
preLoaderRoute: typeof CallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/': {
id: '/_auth/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRouteRoute
}
'/_auth/beneficiaries/$beneficiaryId': {
id: '/_auth/beneficiaries/$beneficiaryId'
path: '/beneficiaries/$beneficiaryId'
fullPath: '/beneficiaries/$beneficiaryId'
preLoaderRoute: typeof AuthBeneficiariesBeneficiaryIdRouteImport
parentRoute: typeof AuthRouteRoute
}
}
}
interface AuthRouteRouteChildren {
AuthIndexRoute: typeof AuthIndexRoute
AuthBeneficiariesBeneficiaryIdRoute: typeof AuthBeneficiariesBeneficiaryIdRoute
}
const AuthRouteRouteChildren: AuthRouteRouteChildren = {
AuthIndexRoute: AuthIndexRoute,
AuthBeneficiariesBeneficiaryIdRoute: AuthBeneficiariesBeneficiaryIdRoute,
}
const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
AuthRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
AuthRouteRoute: AuthRouteRouteWithChildren,
CallbackRoute: CallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

19
app/src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import type { AuthContext } from "@/services/auth.ts";
export const Route = createRootRouteWithContext<{
auth: AuthContext;
}>()({
component: RootComponent,
});
function RootComponent() {
return (
<>
<Outlet />
<TanStackRouterDevtools position="bottom-right" initialIsOpen={false} />
</>
);
}

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { BeneficiaryComponent } from "@/components/beneficiary/read.component.tsx";
export const Route = createFileRoute("/_auth/beneficiaries/$beneficiaryId")({
component: BeneficiaryRoute,
});
function BeneficiaryRoute() {
const { beneficiaryId } = Route.useParams();
return <BeneficiaryComponent id={beneficiaryId} />;
}

View File

@@ -1,8 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { DashboardController } from "@/controllers/dashboard.controller.ts";
import { makeControllerComponent } from "@/lib/controller.tsx";
import { PaymentDashboardController } from "./dashboard.controller.ts";
export const PaymentDashboardView = makeControllerComponent(PaymentDashboardController, ({ beneficiaries }) => {
const DashboardComponent = makeControllerComponent(DashboardController, ({ beneficiaries }) => {
if (beneficiaries.length === 0) {
return (
<div className="w-full flex flex-col pt-20 items-center justify-center text-center">
@@ -20,3 +21,7 @@ export const PaymentDashboardView = makeControllerComponent(PaymentDashboardCont
</div>
);
});
export const Route = createFileRoute("/_auth/")({
component: DashboardComponent,
});

View File

@@ -1,10 +1,20 @@
import { Outlet } from "@tanstack/react-router";
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export function AppView() {
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ context: { auth } }) => {
await auth.resolve();
if (auth.isAuthenticated === false) {
throw auth.login();
}
},
component: AppLayout,
});
function AppLayout() {
return (
<SidebarProvider
style={

View File

@@ -0,0 +1,35 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import z from "zod";
import { zitadel } from "@/services/zitadel.ts";
export const Route = createFileRoute("/callback")({
validateSearch: z.object({
code: z.string(),
}),
component: AuthComponent,
});
function AuthComponent() {
const navigate = useNavigate();
const [error, setError] = useState();
useEffect(() => {
zitadel.userManager
.signinRedirectCallback()
.then(() => {
navigate({ to: "/", replace: true });
})
.catch((error) => {
console.error("Callback error", error);
setError(error);
});
}, [navigate]);
if (error) {
return <div>Error!</div>;
}
return null;
}

32
app/src/services/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { User } from "@/services/user.ts";
import { zitadel } from "@/services/zitadel.ts";
export const auth: AuthContext = {
get isAuthenticated() {
return User.isAuthenticated;
},
get user() {
return User.instance;
},
async resolve() {
await User.resolve();
},
async login() {
await zitadel.authorize();
},
async logout() {
await zitadel.signout();
},
};
export type AuthContext = {
isAuthenticated: boolean;
user: User;
resolve: () => Promise<void>;
login: () => Promise<void>;
logout: () => Promise<void>;
};

View File

@@ -1,4 +1,3 @@
import { redirect } from "@tanstack/react-router";
import z from "zod";
import { type ZitadelUser, zitadel } from "./zitadel.ts";
@@ -35,20 +34,23 @@ export class User {
this.tenant = tenant;
}
static async getTenantId() {
return User.get().then((user) => user.tenant.id);
static get isAuthenticated() {
return cached !== undefined;
}
static async getTenantName() {
return User.get().then((user) => user.tenant.name);
}
static async get() {
const user = await User.resolve();
if (user === undefined) {
throw redirect({ to: "/login" });
static get instance() {
if (cached === undefined) {
throw zitadel.authorize();
}
return user;
return cached;
}
static get tenantId() {
return User.instance.tenant.id;
}
static get tenantName() {
return User.instance.tenant.name;
}
static async resolve() {

View File

@@ -4,7 +4,7 @@ const config: ZitadelConfig = {
authority: "https://iam.valkyrjs.com",
project_resource_id: "348389288439709700",
client_id: "348389308220112900",
redirect_uri: "http://localhost:5173/auth/callback",
redirect_uri: "http://localhost:5173/callback",
post_logout_redirect_uri: "http://localhost:5173",
response_type: "code",
scope: "openid profile email urn:zitadel:iam:user:metadata urn:zitadel:iam:org:id:348388915649970180",

View File

@@ -1,12 +1,20 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
tailwindcss(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -37,7 +37,7 @@
"tailwindcss": "4.1.13",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "1.4.0",
"zod": "4.1.13"
"zod": "4.3.5"
},
"devDependencies": {
"@eslint/js": "9.35.0",

View File

@@ -1,4 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"formatter": {
"enabled": true,
"formatWithErrors": false,

762
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,34 +12,34 @@ services:
# --------------------------------------------------------------------------------
# Used by event store and read store for managing and reading application data.
mongo:
restart: unless-stopped
image: mongo:8
container_name: boilerplate_mongo
ports:
- 6017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password
volumes:
- mongo:/data/db
networks:
- server
# mongo:
# restart: unless-stopped
# image: mongo:8
# container_name: boilerplate_mongo
# ports:
# - 6017:27017
# environment:
# MONGO_INITDB_ROOT_USERNAME: root
# MONGO_INITDB_ROOT_PASSWORD: password
# volumes:
# - mongo:/data/db
# networks:
# - server
# Cerbos
# --------------------------------------------------------------------------------
# Policy engine for application access control.
cerbos:
restart: unless-stopped
image: ghcr.io/cerbos/cerbos:latest
container_name: boilerplate_cerbos
command: ["server", "--config=/config.yaml"]
ports:
- 6592:3592
- 6593:3593
- 6594:3594
volumes:
- ./cerbos.yaml:/config.yaml
networks:
- server
# cerbos:
# restart: unless-stopped
# image: ghcr.io/cerbos/cerbos:latest
# container_name: boilerplate_cerbos
# command: ["server", "--config=/config.yaml"]
# ports:
# - 6592:3592
# - 6593:3593
# - 6594:3594
# volumes:
# - ./cerbos.yaml:/config.yaml
# networks:
# - server

View File

@@ -9,6 +9,6 @@
},
"dependencies": {
"@platform/relay": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -12,6 +12,6 @@
"@platform/database": "workspace:*",
"@platform/parse": "workspace:*",
"@platform/relay": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -7,47 +7,72 @@ import { type Account, type AccountInsert, AccountInsertSchema, AccountSchema }
* Create a new account.
*
* @param values - Account values to insert.
*
* @returns Account _id
*/
export async function createAccount(values: AccountInsert): Promise<string> {
return db
.begin(async () => {
const _id = crypto.randomUUID();
// Assert wallet exists
// ### Assert Wallet
// Ensure that the wallet we are creating an account for exists.
await db.sql`
ASSERT EXISTS (
SELECT 1
FROM payment.wallet w
WHERE w._id = ${db.text(values.walletId)}
), 'missing_wallet';
SELECT
1
FROM
payment.wallet wallet
WHERE
wallet._id = ${db.text(values.walletId)}
),
'missing_wallet';
`;
// Assert wallet → ledger relationship exists AND ledger exists
// ### Assert Wallet → Ledger
// Ensure that the wallet is related to a existing ledger.
await db.sql`
ASSERT EXISTS (
SELECT 1
FROM payment.wallet w
JOIN payment.ledger l ON l._id = w."ledgerId"
WHERE w._id = ${db.text(values.walletId)}
), 'missing_ledger';
SELECT
1
FROM
payment.wallet wallet
JOIN
payment.ledger ledger ON ledger._id = wallet."ledgerId"
WHERE
wallet._id = ${db.text(values.walletId)}
),
'missing_ledger';
`;
// Assert ledger supports the currency
// ### Assert Currency
// Ensure that the account currency is supported by the ledger.
await db.sql`
ASSERT EXISTS (
SELECT 1
FROM payment.wallet w
JOIN payment.ledger l ON l._id = w."ledgerId"
WHERE w._id = ${db.text(values.walletId)}
AND ${db.text(values.currency)} IN (
SELECT
1
FROM
payment.wallet wallet
JOIN
payment.ledger ledger ON ledger._id = wallet."ledgerId"
WHERE
wallet._id = ${db.text(values.walletId)}
AND
${db.text(values.currency)} IN (
SELECT
currency
FROM
UNNEST(l.currencies) AS x(currency)
UNNEST(ledger.currencies) AS x(currency)
)
), 'unsupported_currency';
),
'unsupported_currency';
`;
// ### Create Account
await db.sql`INSERT INTO payment.account RECORDS ${db.transit({ _id, ...AccountInsertSchema.parse(values) })}`;
return _id;
@@ -71,6 +96,13 @@ export async function createAccount(values: AccountInsert): Promise<string> {
});
}
/**
* Get all accounts registered for a wallet.
*
* @param walletId - Wallet to fetch accounts from.
*
* @returns List of wallet accounts
*/
export async function getAccountsByWalletId(walletId: string): Promise<Account[]> {
return db.schema(AccountSchema).many`
SELECT

View File

@@ -56,6 +56,8 @@ export async function getBeneficiaryByTenantId(tenantId: string): Promise<Benefi
* Get a beneficiary entity by provided id.
*
* @param id - Identity of the beneficiary to retrieve.
*
* @returns Beneficiary or undefined
*/
export async function getBeneficiaryById(id: string): Promise<Beneficiary | undefined> {
return db.schema(BeneficiarySchema).one`

View File

@@ -7,12 +7,31 @@ import { type Ledger, type LedgerInsert, LedgerSchema } from "../schemas/ledger.
* Create a new ledger.
*
* @param values - Ledger values to insert.
*
* @returns Ledger _id
*/
export async function createLedger(values: LedgerInsert): Promise<string> {
return db
.begin(async () => {
const _id = crypto.randomUUID();
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.beneficiary WHERE _id = ${db.text(values.beneficiaryId)}), 'missing_beneficiary'`;
// ### Assert Beneficiary
// Ensure the beneficiary for the ledger exists.
await db.sql`
ASSERT EXISTS (
SELECT
1
FROM
payment.beneficiary
WHERE
_id = ${db.text(values.beneficiaryId)}
),
'missing_beneficiary'
`;
// ### Create Ledger
await db.sql`
INSERT INTO payment.ledger (
_id,
@@ -26,6 +45,7 @@ export async function createLedger(values: LedgerInsert): Promise<string> {
${values.label ? db.text(values.label) : null}
)
`;
return _id;
})
.catch((error) => {

View File

@@ -7,13 +7,33 @@ import { type Wallet, type WalletInsert, WalletInsertSchema, WalletSchema } from
* Create a new wallet.
*
* @param values - Wallet values to insert.
*
* @returns Wallet _id
*/
export async function createWallet(values: WalletInsert): Promise<string> {
return db
.begin(async () => {
const _id = crypto.randomUUID();
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.ledger WHERE _id = ${db.text(values.ledgerId)}), 'missing_ledger'`;
// ### Assert Ledger
// Ensure the ledger for the wallet exists.
await db.sql`
ASSERT EXISTS (
SELECT
1
FROM
payment.ledger
WHERE
_id = ${db.text(values.ledgerId)}
),
'missing_ledger'
`;
// ### Create Wallet
await db.sql`INSERT INTO payment.wallet RECORDS ${db.transit({ _id, ...WalletInsertSchema.parse(values) })}`;
return _id;
})
.catch((error) => {

View File

@@ -11,6 +11,6 @@
"@platform/database": "workspace:*",
"@platform/parse": "workspace:*",
"@platform/relay": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -9,6 +9,6 @@
},
"dependencies": {
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -13,6 +13,6 @@
"@types/transit-js": "0.8.3",
"postgres": "3.4.7",
"transit-js": "0.8.874",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -10,6 +10,6 @@
"dependencies": {
"@platform/config": "workspace:*",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -8,6 +8,6 @@
".": "./mod.ts"
},
"dependencies": {
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -12,6 +12,6 @@
"@platform/socket": "workspace:*",
"@platform/supertokens": "workspace:*",
"path-to-regexp": "8",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -5,6 +5,6 @@
"type": "module",
"dependencies": {
"@platform/relay": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -11,6 +11,6 @@
"@platform/socket": "workspace:*",
"@platform/storage": "workspace:*",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"zod": "4.1.13"
"zod": "4.3.5"
}
}

View File

@@ -6,6 +6,6 @@
"dependencies": {
"@platform/models": "workspace:*",
"@platform/relay": "workspace:*",
"zod": "4.1.13"
"zod": "4.3.5"
}
}