feat: add auth to tui

This commit is contained in:
Max Koon
2025-11-17 10:08:10 -05:00
parent 114eaf88eb
commit 9e11455db1
17 changed files with 388 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { bearer, deviceAuthorization, genericOAuth } from "better-auth/plugins";
import { expo } from "@better-auth/expo";
import { drizzleSchema } from "@money/shared/db";
import { db } from "./db";
@@ -37,6 +37,8 @@ export const auth = betterAuth({
scopes: ["profile", "email"],
}
]
})
}),
deviceAuthorization(),
bearer(),
]
});

View File

@@ -99,6 +99,8 @@ const createMutators = (authData: AuthData | null) => {
id: randomUUID(),
userId: authData.user.id,
token: data.access_token,
logoUrl: "",
name: ""
});
},

View File

@@ -45,6 +45,7 @@ export default function RootLayout() {
<Stack>
<Stack.Protected guard={!isPending && !!session}>
<Stack.Screen name="[...route]" options={{ headerShown: false }} />
<Stack.Screen name="approve" />
</Stack.Protected>
<Stack.Protected guard={!isPending && !session}>
<Stack.Screen name="auth" />

23
apps/expo/app/approve.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { authClient } from "@/lib/auth-client";
import { useLocalSearchParams } from "expo-router";
import { useEffect } from "react";
import { Text } from "react-native";
export default function Page() {
const { code } = useLocalSearchParams<{code: string }>();
const { isPending, data } = authClient.useSession();
if (isPending) return <Text>Loading...</Text>;
if (!isPending && !data) return <Text>Please log in</Text>;
useEffect(() => {
authClient.device.approve({
userCode: code,
});
}, []);
return <Text>
Approving: {code}
</Text>
}

View File

@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import { deviceAuthorizationClient, genericOAuthClient } from "better-auth/client/plugins";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import { BASE_URL } from "@money/shared";
@@ -13,5 +13,6 @@ export const authClient = createAuthClient({
storage: SecureStore,
}),
genericOAuthClient(),
deviceAuthorizationClient(),
]
});

View File

@@ -0,0 +1,11 @@
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "http://laptop:3000",
plugins: [
deviceAuthorizationClient(),
]
});

View File

@@ -9,10 +9,12 @@
"react": "*"
},
"dependencies": {
"@money/ui": "workspace:*",
"@money/shared": "workspace:*",
"@money/ui": "workspace:*",
"@opentui/core": "^0.1.39",
"@opentui/react": "^0.1.39",
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react-native": "^0.82.1",
"react-native-opentui": "workspace:*"
},

79
apps/tui/src/auth.tsx Normal file
View File

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,19 @@
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: () => {}
});

View File

@@ -1,28 +1,57 @@
import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core";
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { App, type Route } from "@money/ui";
import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from '@money/shared';
import { useState } from "react";
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";
const userID = "anon";
const server = "http://laptop:4848";
const auth = undefined;
// const auth = undefined;
const PATH = join(homedir(), ".local", "share", "money");
function Main({ auth: initalAuth }: { auth: AuthType | null }) {
const [auth, setAuth] = useState(initalAuth);
function Main() {
return (
<ZeroProvider {...{ userID, auth, server, schema }}>
<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>
);
}
function Router() {
const { auth } = use(AuthContext);
const [route, setRoute] = useState<Route>("/");
return (
<App
auth={null}
auth={auth?.auth || null}
route={route}
setRoute={setRoute}
/>
@@ -30,4 +59,5 @@ function Router() {
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<Main />);
const auth = existsSync(PATH + "/auth.json") ? JSON.parse(readFileSync(PATH + "/auth.json", 'utf8')) as AuthType : null;
createRoot(renderer).render(<Main auth={auth} />);

View File

@@ -1,7 +1,8 @@
{
"compilerOptions": {
"paths": {
"react-native": ["../react-native-opentui"]
"react-native": ["../react-native-opentui"],
"@/*": ["./*"]
},
// Environment setup & latest features
"lib": ["ESNext"],

26
apps/tui/util/qr.ts Normal file
View File

@@ -0,0 +1,26 @@
import QRCode from "qrcode";
export function QR(value: string): string {
const qr = QRCode.create(value, {
errorCorrectionLevel: "L",
version: 3,
});
const m = qr.modules.data;
const size = qr.modules.size;
// Use half-block characters to compress vertically
// Upper half = '▀', lower half = '▄', full = '█', empty = ' '
let out = "";
for (let y = 0; y < size; y += 2) {
for (let x = 0; x < size; x++) {
const top = m[y * size + x];
const bottom = m[(y + 1) * size + x];
out += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
}
out += "\n";
}
return out;
}