diff --git a/apps/tui/build.ts b/apps/tui/build.ts index a3369e4..d49343c 100644 --- a/apps/tui/build.ts +++ b/apps/tui/build.ts @@ -32,7 +32,13 @@ await esbuild.build({ "@opentui/core", "@opentui/react", "@opentui/react/jsx-runtime", + "effect", + "@effect/platform", + "@effect/platform-bun", "bun:ffi", + "@rocicorp/zero", + "better-auth", + "zod", // "./assets/**/*.scm", // "./assets/**/*.wasm", ], diff --git a/apps/tui/src/auth.ts b/apps/tui/src/auth.ts index 1640df0..554fefe 100644 --- a/apps/tui/src/auth.ts +++ b/apps/tui/src/auth.ts @@ -1,26 +1,18 @@ -import { Context, Data, Effect, Layer, Schema, Console, Schedule, Match, Ref, Duration } from "effect"; +import { Context, Data, Effect, Layer, Schema, Console, Schedule, Ref, Duration } from "effect"; import { FileSystem } from "@effect/platform"; import { config } from "./config"; import { AuthState } from "./schema"; import { authClient } from "@/lib/auth-client"; import type { BetterFetchResponse } from "@better-fetch/fetch"; -const CLIENT_ID = "koon-family"; - -const getFromFromDisk = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const content = yield* fs.readFileString(config.authPath); - const auth = yield* Schema.decode(Schema.parseJson(AuthState))(content); - if (auth.session.expiresAt < new Date()) yield* Effect.fail("Token expired"); - return auth; -}); - - -class AuthClientErrorString extends Data.TaggedError("AuthClientErrorString")<{ - errorString: string, +class AuthClientUnknownError extends Data.TaggedError("AuthClientUnknownError") {}; +class AuthClientExpiredToken extends Data.TaggedError("AuthClientExpiredToken") {}; +class AuthClientNoData extends Data.TaggedError("AuthClientNoData") {}; +class AuthClientFetchError extends Data.TaggedError("AuthClientFetchError")<{ message: string, }> {}; +class AuthClientError extends Data.TaggedError("AuthClientError")<{ + error: T, }> {}; - type ErrorType = { [key in keyof ((E extends Record ? E : { message?: string; }) & { @@ -33,16 +25,12 @@ type ErrorType = { [key in keyof ((E extends Record ? E : { statusText: string; })[key]; }; -class AuthClientError extends Data.TaggedError("AuthClientError")<{ - error: T, -}> {}; - export class AuthClient extends Context.Tag("AuthClient")() {}; export interface AuthClientImpl { use: ( fn: (client: typeof authClient) => Promise>, - ) => Effect.Effect> | AuthClientErrorString, never> + ) => Effect.Effect> | AuthClientFetchError | AuthClientUnknownError | AuthClientNoData, never> } @@ -53,16 +41,18 @@ export const make = () => Effect.gen(function* () { const { data, error } = yield* Effect.tryPromise({ try: () => fn(authClient), - catch: () => new AuthClientErrorString({ errorString: "Bad" }), + catch: (error) => error instanceof Error + ? new AuthClientFetchError({ message: error.message }) + : new AuthClientUnknownError() }); if (error != null) return yield* Effect.fail(new AuthClientError({ error })); - if (data == null) return yield* Effect.fail(new AuthClientErrorString({ errorString: "No data" })); + if (data == null) return yield* Effect.fail(new AuthClientNoData()); return data; - }) - }) - }) + }), + }); + }); -export const layer = () => Layer.scoped(AuthClient, make()); +export const AuthClientLayer = Layer.scoped(AuthClient, make()); const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(function* () { const auth = yield* AuthClient; @@ -74,8 +64,8 @@ const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(funct return client.device.token({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, - client_id: CLIENT_ID, - fetchOptions: { headers: { "user-agent": "CLI" } }, + client_id: config.authClientId, + fetchOptions: { headers: { "user-agent": config.authClientUserAgent } }, }) } ); @@ -104,10 +94,18 @@ const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(funct }); +const getFromFromDisk = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(config.authPath); + const auth = yield* Schema.decode(Schema.parseJson(AuthState))(content); + if (auth.session.expiresAt < new Date()) yield* Effect.fail(new AuthClientExpiredToken()); + return auth; +}); + const requestAuth = Effect.gen(function* () { const auth = yield* AuthClient; const { device_code, user_code } = yield* auth.use(client => client.device.code({ - client_id: CLIENT_ID, + client_id: config.authClientId, scope: "openid profile email", })); @@ -123,7 +121,7 @@ const requestAuth = Effect.gen(function* () { } } })); - if (sessionData == null) return yield* Effect.fail("Session was null"); + if (sessionData == null) return yield* Effect.fail(new AuthClientNoData()); const result = yield* Schema.decodeUnknown(AuthState)(sessionData) @@ -135,6 +133,34 @@ const requestAuth = Effect.gen(function* () { export const getAuth = Effect.gen(function* () { return yield* getFromFromDisk.pipe( - Effect.catchAll(() => requestAuth) + Effect.catchAll(() => requestAuth), + Effect.catchTag("AuthClientFetchError", (err) => Effect.gen(function* () { + yield* Console.error("Authentication failed: " + err.message); + process.exit(1); + })), + Effect.catchTag("AuthClientNoData", () => Effect.gen(function* () { + yield* Console.error("Authentication failed: No error and no data was given by the auth server."); + process.exit(1); + })), + Effect.catchTag("ParseError", (err) => Effect.gen(function* () { + yield* Console.error("Authentication failed: Auth data failed: " + err.toString()); + process.exit(1); + })), + Effect.catchTag("BadArgument", () => Effect.gen(function* () { + yield* Console.error("Authentication failed: Bad argument"); + process.exit(1); + })), + Effect.catchTag("SystemError", () => Effect.gen(function* () { + yield* Console.error("Authentication failed: System error"); + process.exit(1); + })), + Effect.catchTag("AuthClientError", ({ error }) => Effect.gen(function* () { + yield* Console.error("Authentication error: " + error.statusText); + process.exit(1); + })), + Effect.catchTag("AuthClientUnknownError", () => Effect.gen(function* () { + yield* Console.error("Unknown authentication error"); + process.exit(1); + })), ); }); diff --git a/apps/tui/src/config.ts b/apps/tui/src/config.ts index c6c3ce8..17f69fc 100644 --- a/apps/tui/src/config.ts +++ b/apps/tui/src/config.ts @@ -7,6 +7,8 @@ const AUTH_PATH = join(PATH, "auth.json"); export const config = { dir: PATH, authPath: AUTH_PATH, + authClientId: "koon-family", + authClientUserAgent: "CLI", zeroUrl: "http://laptop:4848", apiUrl: "http://laptop:3000" }; diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index f1b1756..2371604 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -4,7 +4,7 @@ import { App, type Route } from "@money/ui"; import { ZeroProvider } from "@rocicorp/zero/react"; import { schema } from '@money/shared'; import { useState } from "react"; -import { getAuth, layer } from "./auth"; +import { AuthClientLayer, getAuth } from "./auth"; import { Effect } from "effect"; import { BunContext } from "@effect/platform-bun"; import type { AuthData } from "./schema"; @@ -21,7 +21,7 @@ function Main({ auth }: { auth: AuthData }) { return ( @@ -34,7 +34,7 @@ function Main({ auth }: { auth: AuthData }) { const auth = await Effect.runPromise( getAuth.pipe( Effect.provide(BunContext.layer), - Effect.provide(layer()), + Effect.provide(AuthClientLayer), ) ); const renderer = await createCliRenderer({ exitOnCtrlC: false }); diff --git a/apps/tui/src/schema.ts b/apps/tui/src/schema.ts index 8826b38..ff355ca 100644 --- a/apps/tui/src/schema.ts +++ b/apps/tui/src/schema.ts @@ -1,10 +1,12 @@ import { Schema } from "effect"; +const DateFromDateOrString = Schema.Union(Schema.DateFromString, Schema.DateFromSelf); + const SessionSchema = Schema.Struct({ - expiresAt: Schema.DateFromString, + expiresAt: DateFromDateOrString, token: Schema.String, - createdAt: Schema.DateFromString, - updatedAt: Schema.DateFromString, + createdAt: DateFromDateOrString, + updatedAt: DateFromDateOrString, ipAddress: Schema.optional(Schema.NullishOr(Schema.String)), userAgent: Schema.optional(Schema.NullishOr(Schema.String)), userId: Schema.String, @@ -16,8 +18,8 @@ const UserSchema = Schema.Struct({ email: Schema.String, emailVerified: Schema.Boolean, image: Schema.optional(Schema.NullishOr(Schema.String)), - createdAt: Schema.DateFromString, - updatedAt: Schema.DateFromString, + createdAt: DateFromDateOrString, + updatedAt: DateFromDateOrString, id: Schema.String, }); diff --git a/packages/react-native-opentui/index.tsx b/packages/react-native-opentui/index.tsx index 6fa1bb1..cdb49f5 100644 --- a/packages/react-native-opentui/index.tsx +++ b/packages/react-native-opentui/index.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import type { ViewProps, TextProps, PressableProps } from "react-native"; +import type { ViewProps, TextProps, PressableProps, ScrollViewProps } from "react-native"; export function View({ children, style }: ViewProps) { const bg = style && @@ -20,8 +20,26 @@ export function View({ children, style }: ViewProps) { ? style.flex : undefined : undefined; + const flexShrink = style && + 'flexShrink' in style + ? typeof style.flexShrink == 'number' + ? style.flexShrink + : undefined + : undefined; + const overflow = style && + 'overflow' in style + ? typeof style.overflow == 'string' + ? style.overflow + : undefined + : undefined; - return {children} + return {children} } export function Pressable({ children: childrenRaw, style, onPress }: PressableProps) { @@ -69,6 +87,10 @@ export function Text({ style, children }: TextProps) { } +export function ScrollView({ children }: ScrollViewProps) { + return {children} +} + export const Platform = { OS: "tui", }; diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx index 9c4f943..6cae26b 100644 --- a/packages/ui/src/table.tsx +++ b/packages/ui/src/table.tsx @@ -1,5 +1,5 @@ import { createContext, use, useState, type ReactNode } from "react"; -import { View, Text } from "react-native"; +import { View, Text, ScrollView } from "react-native"; import { useKeyboard } from "./useKeyboard"; const HEADER_COLOR = '#7158e2'; @@ -94,15 +94,15 @@ export function Body() { {columns.map(column => {rpad(column.label, columnMap.get(column.name)! - column.label.length + EXTRA)})} - {data.map((row, index) => { - const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom))) + {data.map((row, index) => { + const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom))) - return ( - - - - ); - })} + return ( + + + + ); + })} ) diff --git a/packages/ui/src/transactions.tsx b/packages/ui/src/transactions.tsx index c619e1e..fab6ba8 100644 --- a/packages/ui/src/transactions.tsx +++ b/packages/ui/src/transactions.tsx @@ -2,7 +2,7 @@ import * as Table from "./table"; import { useQuery } from "@rocicorp/zero/react"; import { queries, type Transaction } from '@money/shared'; import { use } from "react"; -import { View, Text } from "react-native"; +import { View, Text, ScrollView } from "react-native"; import { RouterContext } from "."; @@ -28,13 +28,15 @@ export function Transactions() { const [items] = useQuery(queries.allTransactions(auth)); return ( - - - {/* Spacer */} - - + + + + + + + + + ) }