feat: Add auth flow (#146)

This adds a simple way to incorporate a centralized authentication flow.

The idea is to have the user, API and SSH (for machine authentication)
all in one place using `openauthjs` + `SST`

We also have a database now :)

> We are using InstantDB as it allows us to authenticate a use with just
the email. Plus it is super simple simple to use _of course after the
initial fumbles trying to design the db and relationships_
This commit is contained in:
Wanjohi
2025-01-04 00:02:28 +03:00
committed by GitHub
parent 33895974a7
commit fc5a755408
136 changed files with 3512 additions and 1914 deletions

View File

@@ -1,7 +1,14 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"@nestri/eslint-config/qwik.js",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:qwik/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
@@ -12,5 +19,24 @@ module.exports = {
ecmaFeatures: {
jsx: true,
},
}
},
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prefer-spread": "off",
"no-case-declarations": "off",
"no-console": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
},
};

View File

@@ -35,11 +35,10 @@
"@builder.io/qwik-city": "^1.8.0",
"@builder.io/qwik-react": "0.5.0",
"@modular-forms/qwik": "^0.27.0",
"@nestri/eslint-config": "*",
"@nestri/input": "*",
"@nestri/libmoq": "*",
"@nestri/typescript-config": "*",
"@nestri/ui": "*",
"@openauthjs/openauth": "^0.2.6",
"@types/eslint": "8.56.10",
"@types/howler": "^2.2.12",
"@types/node": "^22.5.1",

View File

@@ -1,4 +1,3 @@
// module.exports = require("@nestri/ui/postcss.config");
module.exports = {
plugins: {
tailwindcss: {},

View File

@@ -0,0 +1,9 @@
import { component$ } from "@builder.io/qwik"
export default component$(() => {
return (
<div class="font-title">
Device
</div>
)
})

View File

@@ -0,0 +1,54 @@
import { $, component$, useVisibleTask$ } from "@builder.io/qwik";
import { createClient } from "@openauthjs/openauth/client";
function getHashParams(url: URL) {
const urlString = url.toString()
const hash = urlString.substring(urlString.indexOf('#') + 1); // Extract the part after the #
console.log("url", hash)
const params = new URLSearchParams(hash);
const paramsObj = {} as any;
for (const [key, value] of params.entries()) {
paramsObj[key] = decodeURIComponent(value);
}
console.log(paramsObj)
return paramsObj;
}
function removeURLParams() {
const newURL = window.location.origin + window.location.pathname; // Just origin and path
window.location.replace(newURL);
}
export default component$(() => {
const login = $(async () => {
const client = createClient({
clientID: "www",
issuer: "https://auth.lauryn.dev.nestri.io"
})
const { url } = await client.authorize("http://localhost:5173/login", "token", { pkce: true })
window.location.href = url
})
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
const urlObj = new URL(window.location.href);
const params = getHashParams(urlObj)
if (params.access_token && params.refresh_token) {
localStorage.setItem("access_token", params.access_token)
localStorage.setItem("refresh_token", params.refresh_token)
removeURLParams()
}
})
return (
<div class="h-screen w-screen flex justify-center items-center">
<button class="px-2 py-1 font-title text-lg bg-gray-400 rounded-lg" onClick$={login}>
Login
</button>
</div>
)
})

View File

@@ -1,119 +0,0 @@
import * as v from "valibot"
//FIXME: Make sure this works
// import { Broadcast } from "./tester";
import { cn } from "@nestri/ui/design";
import { routeLoader$ } from "@builder.io/qwik-city";
import { component$, $, useSignal } from "@builder.io/qwik";
import { MotionComponent, transition, TitleSection, Button } from "@nestri/ui/react";
import { type InitialValues, type SubmitHandler, useForm, valiForm$ } from "@modular-forms/qwik"
const Schema = v.object({
url: v.pipe(
v.string(),
v.minLength(10, "Please input a valid url"),
v.url("Please input a valid url"),
)
}, "Please fill in all the fields correctly.")
type Form = v.InferInput<typeof Schema>;
export const useFormLoader = routeLoader$<InitialValues<Form>>(async () => {
return {
url: ""
}
})
const generateRandomWord = (length: number) => {
const characters = 'abcdefghijklmnopqrstuvwxyz';
return Array.from({ length }, () => characters[Math.floor(Math.random() * characters.length)]).join('');
};
export default component$(() => {
const broadcasterOk = useSignal<boolean | undefined>();
const [state, { Form, Field }] = useForm<Form>({
loader: useFormLoader(),
validate: valiForm$(Schema)
});
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
const randomNamespace = generateRandomWord(6);
// const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
// setTimeout(() => {
// broadcasterOk.value = sub.isSubscribed()
// }, 1000);
});
return (
<>
<TitleSection client:load title="MoQ Checker" description="Test the connection to your Media-Over-Quic relay!" />
<MotionComponent
initial={{ opacity: 0, y: 100 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={transition}
client:load
class="flex items-center justify-center w-full"
as="div"
>
<section class="w-full flex flex-col gap-4 justify-center items-center">
<Form onSubmit$={handleSubmit} class="w-full max-w-xl flex px-3 gap-2">
<Field name="url">
{(field, props) => {
return (
<div class="w-full flex flex-col gap-2">
<div class="bg-gray-200 dark:bg-gray-800 flex rounded-lg w-full relative h-10 flex-none border focus-within:bg-gray-300/70 dark:focus-within:bg-gray-700/70 border-gray-300 dark:border-gray-700 ">
<input type="url" class={cn("w-full relative h-full bg-transparent rounded-lg p-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-400 dark:focus-within:ring-gray-600 focus-within:ring-offset-2 focus-visible:outline-none focus-within:ring-offset-gray-100 dark:focus-within:ring-offset-gray-900 placeholder:text-gray-500/70", typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-green-500", typeof broadcasterOk.value !== "undefined" && (broadcasterOk.value == false) && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-red-500")} placeholder="https://relay.domain.com" {...props} />
</div>
{field.error && (<p class='text-[0.8rem] font-medium text-danger-600 dark:text-danger-500' >{field.error}</p>)}
</div>
)
}}
</Field>
{/* <button class={cn(buttonVariants.solid({ size: "md", intent: "neutral" }), "w-max space-y-0 relative")} style={{ height: 40, marginTop: 0 }} type="submit" >
Check
</button> */}
<Button.Root
disabled={state.submitting}
isLoading={state.submitting}
// setIsLoading={setIsLoading}
client:load
//@ts-ignore
type="submit"
style={{ height: 40, marginTop: 0 }}
intent="neutral"
size="md"
class="w-max space-y-0 relative">
{/* <Button.Icon
isLoading={isLoading.value}
client:load>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
</g>
</svg>
</Button.Icon> */}
<Button.Label
loadingText="Checking..."
class="text-ellipsis whitespace-nowrap"
isLoading={state.submitting}>
Check
</Button.Label>
<div class="w-[8%]" />
</Button.Root>
</Form>
{typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true ? (
<span class="w-full text-green-500 max-w-xl flex space-y-6 px-3 gap-2">
Your relay is doing okay
</span>
) : typeof broadcasterOk.value !== "undefined" && (
<span class="w-full text-red-500 max-w-xl flex space-y-6 px-3 gap-2">
Your relay has an issue
</span>
)}
</section>
</MotionComponent>
</>
)
})

View File

@@ -1,208 +0,0 @@
// import type { Connection, SubscribeRecv } from "@nestri/libmoq/transport"
// import { asError } from "@nestri/moq/common/error"
// import { Client } from "@nestri/moq/transport/client"
// import * as Catalog from "@nestri/moq/media/catalog"
// import { type GroupWriter } from "@nestri/moq/transport/objects"
// export interface BroadcastConfig {
// namespace: string
// connection: Connection
// }
// export interface BroadcasterConfig {
// url: string
// namespace: string
// fingerprint?: string // URL to fetch TLS certificate fingerprint
// }
// export interface BroadcastConfigTrack {
// input: string
// bitrate: number
// }
// export class Broadcast {
// stream: GroupWriter | null
// subscriber: SubscribeRecv | null
// subscribed: boolean;
// readonly config: BroadcastConfig
// readonly catalog: Catalog.Root
// readonly connection: Connection
// readonly namespace: string
// #running: Promise<void>
// constructor(config: BroadcastConfig) {
// this.subscribed = false
// this.namespace = config.namespace
// this.connection = config.connection
// this.config = config
// //Arbitrary values, just to keep TypeScript happy :)
// this.catalog = {
// version: 1,
// streamingFormat: 1,
// streamingFormatVersion: "0.2",
// supportsDeltaUpdates: false,
// commonTrackFields: {
// packaging: "loc",
// renderGroup: 1,
// },
// tracks: [{
// name: "tester",
// namespace: "tester",
// selectionParams: {}
// }],
// }
// this.stream = null
// this.subscriber = null
// this.#running = this.#run()
// }
// static async init(config: BroadcasterConfig): Promise<Broadcast> {
// const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
// const connection = await client.connect();
// return new Broadcast({ connection, namespace: config.namespace })
// }
// async #run() {
// try {
// await this.connection.announce(this.namespace)
// this.subscribed = true
// } catch (error) {
// this.subscribed = false
// }
// for (; ;) {
// const subscriber = await this.connection.subscribed()
// if (!subscriber) {
// this.subscribed = false
// break
// }
// await subscriber.ack()
// this.subscriber = subscriber
// this.subscribed = true
// const bytes = Catalog.encode(this.catalog);
// const stream = await subscriber.group({ group: 0 });
// await stream.write({ object: 0, payload: bytes })
// this.stream = stream
// }
// }
// isSubscribed(): boolean {
// return this.subscribed;
// }
// // async #serveSubscribe(subscriber: SubscribeRecv) {
// // try {
// // // Send a SUBSCRIBE_OK
// // await subscriber.ack()
// // console.log("catalog track name:", subscriber.track)
// // const stream = await subscriber.group({ group: 0 });
// // // const bytes = this.catalog.encode("Hello World")
// // await stream.write({ object: 0, payload: bytes })
// // } catch (e) {
// // const err = asError(e)
// // await subscriber.close(1n, `failed to process publish: ${err.message}`)
// // } finally {
// // // TODO we can't close subscribers because there's no support for clean termination
// // // await subscriber.close()
// // }
// // }
// // async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
// // const mouse_move = {
// // input_type: "mouse_move",
// // delta_y: y,
// // delta_x: x,
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
// // const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
// // if (e.type === "mousedown") {
// // data["input_type"] = "mouse_key_down"
// // } else if (e.type === "mouseup") {
// // data["input_type"] = "mouse_key_up"
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
// // const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
// // if (e.deltaY < 0.0) {
// // data["input_type"] = "mouse_wheel_up"
// // } else {
// // data["input_type"] = "mouse_wheel_down"
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
// // const data = {
// // input_type: "key_up",
// // key_code: e.keyCode
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// // async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
// // const data = {
// // input_type: "key_down",
// // key_code: e.keyCode
// // }
// // const bytes = Catalog.encode(this.catalog)
// // await stream.write({ object: 0, payload: bytes });
// // }
// close() {
// // TODO implement publish close
// }
// // Returns the error message when the connection is closed
// async closed(): Promise<Error> {
// try {
// await this.#running
// return new Error("closed") // clean termination
// } catch (e) {
// return asError(e)
// }
// }
// }

View File

@@ -1,12 +1,5 @@
import { component$ } from "@builder.io/qwik";
import { HomeNavBar, Card } from "@nestri/ui";
function getGreeting(): string {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return "Good Morning";
if (hour >= 12 && hour < 18) return "Good Afternoon";
return "Good Evening";
}
import { HomeNavBar } from "@nestri/ui";
export default component$(() => {
return (

View File

@@ -1,12 +1,13 @@
import {useLocation} from "@builder.io/qwik-city";
import {Keyboard, Mouse, WebRTCStream} from "@nestri/input"
import {component$, useSignal, useVisibleTask$} from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import { Keyboard, Mouse, WebRTCStream } from "@nestri/input"
import { component$, useSignal, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
const id = useLocation().params.id;
const canvas = useSignal<HTMLCanvasElement>();
useVisibleTask$(({track}) => {
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => canvas.value);
if (!canvas.value) return; // Ensure canvas is available
@@ -66,9 +67,9 @@ export default component$(() => {
// @ts-ignore
if (document.pointerLockElement && !window.nestrimouse && !window.nestrikeyboard) {
// @ts-ignore
window.nestrimouse = new Mouse({canvas: canvas.value, webrtc});
window.nestrimouse = new Mouse({ canvas: canvas.value, webrtc });
// @ts-ignore
window.nestrikeyboard = new Keyboard({canvas: canvas.value, webrtc});
window.nestrikeyboard = new Keyboard({ canvas: canvas.value, webrtc });
// @ts-ignore
} else if (!document.pointerLockElement && window.nestrimouse && window.nestrikeyboard) {
// @ts-ignore
@@ -180,7 +181,7 @@ export default component$(() => {
}
}}
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
class="aspect-video h-full w-full object-contain max-h-screen"/>
class="aspect-video h-full w-full object-contain max-h-screen" />
)
})

View File

@@ -1,8 +1,8 @@
import { $, component$, noSerialize, NoSerialize, useSignal, useVisibleTask$ } from "@builder.io/qwik";
import { $, component$, noSerialize, type NoSerialize, useSignal, useVisibleTask$ } from "@builder.io/qwik";
import { TitleSection, MotionComponent, transition } from "@nestri/ui/react";
import { NavBar, Footer, Book } from "@nestri/ui"
import { cn } from "@nestri/ui/design";
import { Howl, Howler } from 'howler';
import { Howl } from 'howler';
//FIXME: Add a FAQ section
// FIXME: Takes too long for the price input radio input to become responsive
@@ -68,6 +68,7 @@ export default component$(() => {
const bookRef = useSignal<HTMLButtonElement | undefined>()
const audio = useSignal<NoSerialize<Howl> | undefined>()
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(() => {
audio.value = noSerialize(new Howl({ src: ["/audio/cash.mp3"], volume: 0.5 }))

40
apps/www/sst-env.d.ts vendored
View File

@@ -1,4 +1,42 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/// <reference path="../../sst-env.d.ts" />
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}

View File

@@ -1,22 +1,15 @@
{
"extends": "@nestri/typescript-config/base.json",
"compilerOptions": {
"allowJs": true,
"target": "ES2022",
"target": "ES2017",
"module": "ES2022",
"lib": [
"es2022",
"DOM",
"WebWorker",
"DOM.Iterable"
],
"lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "@builder.io/qwik",
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"skipLibCheck": true,
"incremental": true,
@@ -24,22 +17,9 @@
"outDir": "tmp",
"noEmit": true,
"paths": {
"@/*": [
"./src/*"
],
"content-collections": [
"./.content-collections/generated"
]
"@/*": ["./src/*"]
}
},
"files": [
".eslintrc.cjs"
],
"include": [
"src",
"./*.d.ts",
"./*.config.ts",
"./*.config.js",
"content-collections.ts"
, "../../packages/input/src/webrtc-stream.ts" ]
"files": ["./.eslintrc.cjs"],
"include": ["src", "./*.d.ts", "./*.config.ts","./*.config.cjs"]
}