refactor: fix table and clean up auth code

This commit is contained in:
Max Koon
2025-11-20 11:53:31 -05:00
parent b42da83274
commit 882d437007
8 changed files with 118 additions and 58 deletions

View File

@@ -32,7 +32,13 @@ await esbuild.build({
"@opentui/core", "@opentui/core",
"@opentui/react", "@opentui/react",
"@opentui/react/jsx-runtime", "@opentui/react/jsx-runtime",
"effect",
"@effect/platform",
"@effect/platform-bun",
"bun:ffi", "bun:ffi",
"@rocicorp/zero",
"better-auth",
"zod",
// "./assets/**/*.scm", // "./assets/**/*.scm",
// "./assets/**/*.wasm", // "./assets/**/*.wasm",
], ],

View File

@@ -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 { FileSystem } from "@effect/platform";
import { config } from "./config"; import { config } from "./config";
import { AuthState } from "./schema"; import { AuthState } from "./schema";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import type { BetterFetchResponse } from "@better-fetch/fetch"; import type { BetterFetchResponse } from "@better-fetch/fetch";
const CLIENT_ID = "koon-family"; class AuthClientUnknownError extends Data.TaggedError("AuthClientUnknownError") {};
class AuthClientExpiredToken extends Data.TaggedError("AuthClientExpiredToken") {};
const getFromFromDisk = Effect.gen(function* () { class AuthClientNoData extends Data.TaggedError("AuthClientNoData") {};
const fs = yield* FileSystem.FileSystem; class AuthClientFetchError extends Data.TaggedError("AuthClientFetchError")<{ message: string, }> {};
const content = yield* fs.readFileString(config.authPath); class AuthClientError<T> extends Data.TaggedError("AuthClientError")<{
const auth = yield* Schema.decode(Schema.parseJson(AuthState))(content); error: T,
if (auth.session.expiresAt < new Date()) yield* Effect.fail("Token expired");
return auth;
});
class AuthClientErrorString extends Data.TaggedError("AuthClientErrorString")<{
errorString: string,
}> {}; }> {};
type ErrorType<E> = { [key in keyof ((E extends Record<string, any> ? E : { type ErrorType<E> = { [key in keyof ((E extends Record<string, any> ? E : {
message?: string; message?: string;
}) & { }) & {
@@ -33,16 +25,12 @@ type ErrorType<E> = { [key in keyof ((E extends Record<string, any> ? E : {
statusText: string; statusText: string;
})[key]; }; })[key]; };
class AuthClientError<T> extends Data.TaggedError("AuthClientError")<{
error: T,
}> {};
export class AuthClient extends Context.Tag("AuthClient")<AuthClient, AuthClientImpl>() {}; export class AuthClient extends Context.Tag("AuthClient")<AuthClient, AuthClientImpl>() {};
export interface AuthClientImpl { export interface AuthClientImpl {
use: <T, E>( use: <T, E>(
fn: (client: typeof authClient) => Promise<BetterFetchResponse<T, E>>, fn: (client: typeof authClient) => Promise<BetterFetchResponse<T, E>>,
) => Effect.Effect<T, AuthClientError<ErrorType<E>> | AuthClientErrorString, never> ) => Effect.Effect<T, AuthClientError<ErrorType<E>> | AuthClientFetchError | AuthClientUnknownError | AuthClientNoData, never>
} }
@@ -53,16 +41,18 @@ export const make = () =>
Effect.gen(function* () { Effect.gen(function* () {
const { data, error } = yield* Effect.tryPromise({ const { data, error } = yield* Effect.tryPromise({
try: () => fn(authClient), 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 (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; 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 pollToken = ({ device_code }: { device_code: string }) => Effect.gen(function* () {
const auth = yield* AuthClient; const auth = yield* AuthClient;
@@ -74,8 +64,8 @@ const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(funct
return client.device.token({ return client.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code", grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code, device_code,
client_id: CLIENT_ID, client_id: config.authClientId,
fetchOptions: { headers: { "user-agent": "CLI" } }, 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 requestAuth = Effect.gen(function* () {
const auth = yield* AuthClient; const auth = yield* AuthClient;
const { device_code, user_code } = yield* auth.use(client => client.device.code({ const { device_code, user_code } = yield* auth.use(client => client.device.code({
client_id: CLIENT_ID, client_id: config.authClientId,
scope: "openid profile email", 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) const result = yield* Schema.decodeUnknown(AuthState)(sessionData)
@@ -135,6 +133,34 @@ const requestAuth = Effect.gen(function* () {
export const getAuth = Effect.gen(function* () { export const getAuth = Effect.gen(function* () {
return yield* getFromFromDisk.pipe( 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);
})),
); );
}); });

View File

@@ -7,6 +7,8 @@ const AUTH_PATH = join(PATH, "auth.json");
export const config = { export const config = {
dir: PATH, dir: PATH,
authPath: AUTH_PATH, authPath: AUTH_PATH,
authClientId: "koon-family",
authClientUserAgent: "CLI",
zeroUrl: "http://laptop:4848", zeroUrl: "http://laptop:4848",
apiUrl: "http://laptop:3000" apiUrl: "http://laptop:3000"
}; };

View File

@@ -4,7 +4,7 @@ import { App, type Route } from "@money/ui";
import { ZeroProvider } from "@rocicorp/zero/react"; import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from '@money/shared'; import { schema } from '@money/shared';
import { useState } from "react"; import { useState } from "react";
import { getAuth, layer } from "./auth"; import { AuthClientLayer, getAuth } from "./auth";
import { Effect } from "effect"; import { Effect } from "effect";
import { BunContext } from "@effect/platform-bun"; import { BunContext } from "@effect/platform-bun";
import type { AuthData } from "./schema"; import type { AuthData } from "./schema";
@@ -21,7 +21,7 @@ function Main({ auth }: { auth: AuthData }) {
return ( return (
<ZeroProvider {...{ userID: auth.user.id, auth: auth.session.token, server: config.zeroUrl, schema, kvStore }}> <ZeroProvider {...{ userID: auth.user.id, auth: auth.session.token, server: config.zeroUrl, schema, kvStore }}>
<App <App
auth={auth || null} auth={auth}
route={route} route={route}
setRoute={setRoute} setRoute={setRoute}
/> />
@@ -34,7 +34,7 @@ function Main({ auth }: { auth: AuthData }) {
const auth = await Effect.runPromise( const auth = await Effect.runPromise(
getAuth.pipe( getAuth.pipe(
Effect.provide(BunContext.layer), Effect.provide(BunContext.layer),
Effect.provide(layer()), Effect.provide(AuthClientLayer),
) )
); );
const renderer = await createCliRenderer({ exitOnCtrlC: false }); const renderer = await createCliRenderer({ exitOnCtrlC: false });

View File

@@ -1,10 +1,12 @@
import { Schema } from "effect"; import { Schema } from "effect";
const DateFromDateOrString = Schema.Union(Schema.DateFromString, Schema.DateFromSelf);
const SessionSchema = Schema.Struct({ const SessionSchema = Schema.Struct({
expiresAt: Schema.DateFromString, expiresAt: DateFromDateOrString,
token: Schema.String, token: Schema.String,
createdAt: Schema.DateFromString, createdAt: DateFromDateOrString,
updatedAt: Schema.DateFromString, updatedAt: DateFromDateOrString,
ipAddress: Schema.optional(Schema.NullishOr(Schema.String)), ipAddress: Schema.optional(Schema.NullishOr(Schema.String)),
userAgent: Schema.optional(Schema.NullishOr(Schema.String)), userAgent: Schema.optional(Schema.NullishOr(Schema.String)),
userId: Schema.String, userId: Schema.String,
@@ -16,8 +18,8 @@ const UserSchema = Schema.Struct({
email: Schema.String, email: Schema.String,
emailVerified: Schema.Boolean, emailVerified: Schema.Boolean,
image: Schema.optional(Schema.NullishOr(Schema.String)), image: Schema.optional(Schema.NullishOr(Schema.String)),
createdAt: Schema.DateFromString, createdAt: DateFromDateOrString,
updatedAt: Schema.DateFromString, updatedAt: DateFromDateOrString,
id: Schema.String, id: Schema.String,
}); });

View File

@@ -1,5 +1,5 @@
import * as React from "react"; 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) { export function View({ children, style }: ViewProps) {
const bg = style && const bg = style &&
@@ -20,8 +20,26 @@ export function View({ children, style }: ViewProps) {
? style.flex ? style.flex
: undefined : undefined
: 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 <box backgroundColor={bg} flexDirection={flexDirection} flexGrow={flex}>{children}</box> return <box
backgroundColor={bg}
flexDirection={flexDirection}
flexGrow={flex}
overflow={overflow}
flexShrink={flexShrink}
>{children}</box>
} }
export function Pressable({ children: childrenRaw, style, onPress }: PressableProps) { export function Pressable({ children: childrenRaw, style, onPress }: PressableProps) {
@@ -69,6 +87,10 @@ export function Text({ style, children }: TextProps) {
} }
export function ScrollView({ children }: ScrollViewProps) {
return <scrollbox >{children}</scrollbox>
}
export const Platform = { export const Platform = {
OS: "tui", OS: "tui",
}; };

View File

@@ -1,5 +1,5 @@
import { createContext, use, useState, type ReactNode } from "react"; 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"; import { useKeyboard } from "./useKeyboard";
const HEADER_COLOR = '#7158e2'; const HEADER_COLOR = '#7158e2';
@@ -94,15 +94,15 @@ export function Body() {
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: 'row' }}> <View style={{ backgroundColor: HEADER_COLOR, flexDirection: 'row' }}>
{columns.map(column => <Text key={column.name} style={{ fontFamily: 'mono', color: 'white' }}>{rpad(column.label, columnMap.get(column.name)! - column.label.length + EXTRA)}</Text>)} {columns.map(column => <Text key={column.name} style={{ fontFamily: 'mono', color: 'white' }}>{rpad(column.label, columnMap.get(column.name)! - column.label.length + EXTRA)}</Text>)}
</View> </View>
{data.map((row, index) => { {data.map((row, index) => {
const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom))) const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom)))
return ( return (
<View key={index} style={{ backgroundColor: isSelected ? SELECTED_COLOR : TABLE_COLORS[index % 2] }}> <View key={index} style={{ backgroundColor: isSelected ? SELECTED_COLOR : TABLE_COLORS[index % 2] }}>
<TableRow key={index} row={row as ValidRecord} index={index} isSelected={isSelected} /> <TableRow key={index} row={row as ValidRecord} index={index} isSelected={isSelected} />
</View> </View>
); );
})} })}
</View> </View>
) )

View File

@@ -2,7 +2,7 @@ import * as Table from "./table";
import { useQuery } from "@rocicorp/zero/react"; import { useQuery } from "@rocicorp/zero/react";
import { queries, type Transaction } from '@money/shared'; import { queries, type Transaction } from '@money/shared';
import { use } from "react"; import { use } from "react";
import { View, Text } from "react-native"; import { View, Text, ScrollView } from "react-native";
import { RouterContext } from "."; import { RouterContext } from ".";
@@ -28,13 +28,15 @@ export function Transactions() {
const [items] = useQuery(queries.allTransactions(auth)); const [items] = useQuery(queries.allTransactions(auth));
return ( return (
<Table.Provider <Table.Provider data={items} columns={COLUMNS}>
data={items} <View style={{ flex: 1 }}>
columns={COLUMNS} > <View style={{ flexShrink: 0}}>
<Table.Body /> <Table.Body />
{/* Spacer */} </View>
<View style={{ flex: 1 }} /> </View>
<Selected /> <View style={{ flexShrink: 0 }}>
<Selected />
</View>
</Table.Provider> </Table.Provider>
) )
} }