feat: add auth to tui
This commit is contained in:
11
apps/tui/lib/auth-client.ts
Normal file
11
apps/tui/lib/auth-client.ts
Normal 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(),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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
79
apps/tui/src/auth.tsx
Normal 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} />;
|
||||
}
|
||||
|
||||
|
||||
48
apps/tui/src/auth/code.tsx
Normal file
48
apps/tui/src/auth/code.tsx
Normal 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>
|
||||
}
|
||||
|
||||
|
||||
19
apps/tui/src/auth/context.ts
Normal file
19
apps/tui/src/auth/context.ts
Normal 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: () => {}
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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
26
apps/tui/util/qr.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user