refactor: replace device auth flow with effect

This commit is contained in:
Max Koon
2025-11-17 17:17:54 -05:00
parent 9e11455db1
commit f17daa2c78
9 changed files with 617 additions and 190 deletions

View File

@@ -9,11 +9,14 @@
"react": "*"
},
"dependencies": {
"@effect/platform": "^0.93.2",
"@effect/platform-bun": "^0.83.0",
"@money/shared": "workspace:*",
"@money/ui": "workspace:*",
"@opentui/core": "^0.1.39",
"@opentui/react": "^0.1.39",
"@types/qrcode": "^1.5.6",
"effect": "^3.19.4",
"qrcode": "^1.5.4",
"react-native": "^0.82.1",
"react-native-opentui": "workspace:*"

136
apps/tui/src/auth.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Context, Data, Effect, Layer, Schema, Console, Schedule, Match, 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);
return yield* Schema.decode(Schema.parseJson(AuthState))(content);
});
class AuthClientErrorString extends Data.TaggedError("AuthClientErrorString")<{
errorString: string,
}> {};
type ErrorType<E> = { [key in keyof ((E extends Record<string, any> ? E : {
message?: string;
}) & {
status: number;
statusText: string;
})]: ((E extends Record<string, any> ? E : {
message?: string;
}) & {
status: number;
statusText: string;
})[key]; };
class AuthClientError<T> extends Data.TaggedError("AuthClientError")<{
error: T,
}> {};
export class AuthClient extends Context.Tag("AuthClient")<AuthClient, AuthClientImpl>() {};
export interface AuthClientImpl {
use: <T, E>(
fn: (client: typeof authClient) => Promise<BetterFetchResponse<T, E>>,
) => Effect.Effect<T, AuthClientError<ErrorType<E>> | AuthClientErrorString, never>
}
export const make = () =>
Effect.gen(function* () {
return AuthClient.of({
use: (fn) =>
Effect.gen(function* () {
const { data, error } = yield* Effect.tryPromise({
try: () => fn(authClient),
catch: () => new AuthClientErrorString({ errorString: "Bad" }),
});
if (error != null) return yield* Effect.fail(new AuthClientError({ error }));
if (data == null) return yield* Effect.fail(new AuthClientErrorString({ errorString: "No data" }));
return data;
})
})
})
export const layer = () => Layer.scoped(AuthClient, make());
const pollToken = ({ device_code }: { device_code: string }) => Effect.gen(function* () {
const auth = yield* AuthClient;
const intervalRef = yield* Ref.make(5);
const tokenEffect = auth.use(client => {
Console.debug("Fetching");
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" } },
})
}
);
return yield* tokenEffect
.pipe(
Effect.tapError(error =>
error._tag == "AuthClientError" && error.error.error == "slow_down"
? Ref.update(intervalRef, current => {
Console.debug("updating delay to ", current + 5);
return current + 5
})
: Effect.void
),
Effect.retry({
schedule: Schedule.addDelayEffect(
Schedule.recurWhile<Effect.Effect.Error<typeof tokenEffect>>(error =>
error._tag == "AuthClientError" &&
(error.error.error == "authorization_pending" || error.error.error == "slow_down")
),
() => Ref.get(intervalRef).pipe(Effect.map(Duration.seconds))
)
})
);
});
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,
scope: "openid profile email",
}));
console.log(`Please use the code: ${user_code}`);
const { access_token } = yield* pollToken({ device_code });
const sessionData = yield* auth.use(client => client.getSession({
fetchOptions: {
auth: {
type: "Bearer",
token: access_token,
}
}
}));
if (sessionData == null) return yield* Effect.fail("Session was null");
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(config.authPath, JSON.stringify(sessionData));
return sessionData;
});
export const getAuth = Effect.gen(function* () {
return yield* getFromFromDisk.pipe(
Effect.catchAll(() => requestAuth)
);
});

View File

@@ -1,79 +0,0 @@
import { authClient } from "@/lib/auth-client";
import { use, useEffect, useState } from "react";
import { AuthContext } from "./auth/context";
import * as Code from "./auth/code";
const CLIENT_ID = "koon-family";
export function Auth() {
const { setAuth } = use(AuthContext);
const [code, setCode] = useState<string>();
const [error, setError] = useState<string>();
const pollForToken = async (code: string, interval = 5) => {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: code,
client_id: CLIENT_ID,
fetchOptions: { headers: { "user-agent": "My CLI" } },
});
if (data?.access_token) {
const { data: sessionData, error } = await authClient.getSession({
fetchOptions: {
auth: {
type: "Bearer",
token: data.access_token
}
}
});
if (error) return setError(error.message);
if (!sessionData) return setError("No data");
setAuth({
token: data.access_token,
auth: sessionData,
});
}
if (error) {
if (error.error === "authorization_pending") {
setTimeout(() => pollForToken(code, interval), interval * 1000);
} else if (error.error === "slow_down") {
setTimeout(() => pollForToken(code, interval + 5), (interval + 5) * 1000);
} else {
setError(`${error}`);
}
}
}
async function getCode() {
try {
const { data, error } = await authClient.device.code({
client_id: CLIENT_ID,
scope: "openid profile email",
});
if (error) return setError(error.error_description);
if (!data) return setError("No data returned");
setCode(data.user_code);
pollForToken(data?.device_code);
} catch (e) {
setError(`${e}`);
}
}
useEffect(() => {
getCode();
}, []);
if (error) return <Code.Error msg={error} />
return !code ? <Code.Loading /> : <Code.Display code={code} />;
}

View File

@@ -1,48 +0,0 @@
import { QR } from "@/util/qr";
import type { ReactNode } from "react";
function CodeDisplay({ children }: { children: ReactNode }) {
return (
<box alignItems="center" justifyContent="center" flexGrow={1}>
<box flexDirection="row" gap={2}>
{children}
</box>
</box>
);
}
export function Display({ code }: { code: string }) {
const URL = `https://money.koon.us/approve?code=${code}`;
return <CodeDisplay>
<text fg="black">{QR(URL)}</text>
<box justifyContent="center" gap={1}>
<text fg="black">Welcome to Koon Money</text>
<text fg="black">Go to: {URL}</text>
<text fg="black">Code: {code}</text>
</box>
</CodeDisplay>
}
export function Loading() {
return <CodeDisplay>
<box width={29} height={15} backgroundColor="gray" />
<box justifyContent="center" gap={1}>
<text fg="black">Welcome to Koon Money</text>
<text fg="black">You need to login first</text>
<text fg="black">Loading login information</text>
</box>
</CodeDisplay>
}
export function Error({ msg }: { msg: string }) {
return <CodeDisplay>
<box justifyContent="center" alignItems="center" gap={1}>
<text fg="red">Could not login</text>
<text fg="red">{msg}</text>
</box>
</CodeDisplay>
}

View File

@@ -1,19 +0,0 @@
import { type AuthData } from "@money/shared/auth";
import { createContext } from "react";
export type AuthType = {
auth: AuthData;
token: string;
};
export type AuthContextType = {
auth: AuthType | null;
setAuth: (auth: AuthContextType['auth']) => void;
};
export const AuthContext = createContext<AuthContextType>({
auth: null,
setAuth: () => {}
});

9
apps/tui/src/config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { join } from "path";
import { homedir } from "os";
const PATH = join(homedir(), ".local", "share", "money");
const AUTH_PATH = join(PATH, "auth.json");
export const config = {
authPath: AUTH_PATH,
};

View File

@@ -3,61 +3,36 @@ import { createRoot } from "@opentui/react";
import { App, type Route } from "@money/ui";
import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from '@money/shared';
import { use, useState } from "react";
import { writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { Auth } from "./auth";
import { AuthContext, type AuthType } from "./auth/context";
import { useState } from "react";
import { AuthClient, getAuth, layer } from "./auth";
import { Effect, Layer } from "effect";
import { BunContext } from "@effect/platform-bun";
import type { AuthData } from "./schema";
const userID = "anon";
const server = "http://laptop:4848";
// const auth = undefined;
const PATH = join(homedir(), ".local", "share", "money");
function Main({ auth: initalAuth }: { auth: AuthType | null }) {
const [auth, setAuth] = useState(initalAuth);
function Main({ auth }: { auth: AuthData }) {
const [route, setRoute] = useState<Route>("/");
return (
<AuthContext.Provider value={{ auth, setAuth: (auth) => {
if (auth) {
mkdirSync(PATH, { recursive: true });
writeFileSync(PATH + "/auth.json", JSON.stringify(auth));
setAuth(auth);
} else {
rmSync(PATH + "token");
}
} }}>
{auth ? <Authed /> : <Auth />}
</AuthContext.Provider>
);
}
function Authed() {
const { auth } = use(AuthContext);
return (
<ZeroProvider {...{ userID, auth: auth?.token, server, schema }}>
<Router />
<ZeroProvider {...{ userID, auth: auth.session.token, server, schema }}>
<App
auth={auth || null}
route={route}
setRoute={setRoute}
/>
</ZeroProvider>
);
}
function Router() {
const { auth } = use(AuthContext);
const [route, setRoute] = useState<Route>("/");
return (
<App
auth={auth?.auth || null}
route={route}
setRoute={setRoute}
/>
);
}
const auth = await Effect.runPromise(
getAuth.pipe(
Effect.provide(BunContext.layer),
Effect.provide(layer()),
)
);
const renderer = await createCliRenderer();
const auth = existsSync(PATH + "/auth.json") ? JSON.parse(readFileSync(PATH + "/auth.json", 'utf8')) as AuthType : null;
createRoot(renderer).render(<Main auth={auth} />);

31
apps/tui/src/schema.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Schema } from "effect";
const SessionSchema = Schema.Struct({
expiresAt: Schema.DateFromString,
token: Schema.String,
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString,
ipAddress: Schema.optional(Schema.NullishOr(Schema.String)),
userAgent: Schema.optional(Schema.NullishOr(Schema.String)),
userId: Schema.String,
id: Schema.String,
});
const UserSchema = Schema.Struct({
name: Schema.String,
email: Schema.String,
emailVerified: Schema.Boolean,
image: Schema.optional(Schema.NullishOr(Schema.String)),
createdAt: Schema.DateFromString,
updatedAt: Schema.DateFromString,
id: Schema.String,
});
export const AuthState = Schema.Struct({
session: SessionSchema,
user: UserSchema,
});
export type AuthData = typeof AuthState.Type;