🔨 Add workflow and dockerfile
All checks were successful
release-nightly / build-docker (push) Successful in 3m19s

This commit is contained in:
2024-01-30 18:34:37 +08:00
parent e87da59026
commit 6d056b6e1d
50 changed files with 60 additions and 1684 deletions

5
pkg/view/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
/dist
/node_modules
/package-lock.json
*.lock

24
pkg/view/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
pkg/view/README.md Normal file
View File

@ -0,0 +1,28 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

6
pkg/view/embed.go Normal file
View File

@ -0,0 +1,6 @@
package view
import "embed"
//go:embed all:dist
var FS embed.FS

13
pkg/view/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Goatpass</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

26
pkg/view/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@hydrogen/passport-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"solid-js": "^1.8.7",
"universal-cookie": "^7.0.2"
},
"devDependencies": {
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.0",
"postcss": "^8.4.33",
"solid-devtools": "^0.29.3",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-solid": "^2.8.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

5
pkg/view/src/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false
}

View File

@ -0,0 +1,197 @@
:root {
--bs-body-font-family: "IBM Plex Serif", "Noto Serif SC", sans-serif !important;
}
html,
body {
font-family: var(--bs-body-font-family);
}
/* ibm-plex-serif-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 100;
src: url("./ibm-plex-serif-v19-latin-100.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 100;
src: url("./ibm-plex-serif-v19-latin-100italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 200;
src: url("./ibm-plex-serif-v19-latin-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 200;
src: url("./ibm-plex-serif-v19-latin-200italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 300;
src: url("./ibm-plex-serif-v19-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 300;
src: url("./ibm-plex-serif-v19-latin-300italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 400;
src: url("./ibm-plex-serif-v19-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 400;
src: url("./ibm-plex-serif-v19-latin-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 500;
src: url("./ibm-plex-serif-v19-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 500;
src: url("./ibm-plex-serif-v19-latin-500italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 600;
src: url("./ibm-plex-serif-v19-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 600;
src: url("./ibm-plex-serif-v19-latin-600italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 700;
src: url("./ibm-plex-serif-v19-latin-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 700;
src: url("./ibm-plex-serif-v19-latin-700italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-200 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 200;
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-300 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 300;
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-regular - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 400;
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-500 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 500;
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-600 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 600;
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-700 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 700;
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-900 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 900;
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

8
pkg/view/src/index.css Normal file
View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
}

30
pkg/view/src/index.tsx Normal file
View File

@ -0,0 +1,30 @@
import "solid-devtools";
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import "./assets/fonts/fonts.css";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import RootLayout from "./layouts/RootLayout.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
render(() => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} />
<Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} />
<Route path="/auth/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} />
<Route path="/auth/oauth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
</Router>
</UserinfoProvider>
</WellKnownProvider>
), root!);

View File

@ -0,0 +1,46 @@
import Navbar from "./shared/Navbar.tsx";
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createEffect, createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
import { BeforeLeaveEventArgs, useBeforeLeave, useLocation, useNavigate } from "@solidjs/router";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
const navigate = useNavigate();
const userinfo = useUserinfo();
const location = useLocation();
createEffect(() => {
if (ready()) {
keepGate(location.pathname);
}
}, [ready, userinfo]);
function keepGate(path: string, e?: BeforeLeaveEventArgs) {
const whitelist = ["/auth/login", "/auth/register", "/users/me/confirm"];
if (!userinfo?.isLoggedIn && !whitelist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();
navigate(`/auth/login?redirect_uri=${path}`);
}
}
useBeforeLeave((e: BeforeLeaveEventArgs) => keepGate(e.to.toString().split("?")[0], e));
return (
<Show when={ready()} fallback={
<div class="h-screen w-screen flex justify-center items-center">
<div>
<span class="loading loading-lg loading-infinity"></span>
</div>
</div>
}>
<Navbar />
<main class="h-[calc(100vh-68px)] mt-[68px]">{props.children}</main>
</Show>
);
}

View File

@ -0,0 +1,83 @@
import { For, Match, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import { useWellKnown } from "../../stores/wellKnown.tsx";
interface MenuItem {
label: string;
href: string;
}
export default function Navbar() {
const nav: MenuItem[] = [{ label: "Dashboard", href: "/" }];
const wellKnown = useWellKnown();
const userinfo = useUserinfo();
const navigate = useNavigate();
function logout() {
clearUserinfo();
navigate("/auth/login");
}
return (
<div class="navbar bg-base-100 shadow-md px-5 z-10 fixed top-0">
<div class="navbar-start">
<div class="dropdown">
<div tabIndex={0} role="button" class="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</div>
<ul
tabIndex={0}
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<For each={nav}>
{(item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
)}
</For>
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
{wellKnown?.name ?? "Goatpass"}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<For each={nav}>
{(item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
)}
</For>
</ul>
</div>
<div class="navbar-end pe-5">
<Switch>
<Match when={userinfo?.isLoggedIn}>
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>Logout</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href="/auth/login" class="btn btn-sm btn-primary">Login</a>
</Match>
</Switch>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { useSearchParams } from "@solidjs/router";
export default function DefaultCallbackPage() {
const [searchParams] = useSearchParams();
return (
<div class="w-full h-full flex justify-center items-center">
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
{/* Just Kidding */}
<h1 class="text-xl font-bold">Default Callback</h1>
<p>
If you see this page, it means some genius developer forgot to set the redirect address, so you visited
this default callback address.
General Douglas MacArthur, a five-star general in the United States, commented on this: "If I let my
soldiers use default callbacks, they'd rather die."
The large documentary film "Callback Legend" is currently in theaters.
</p>
</div>
<div class="text-center">
<p>Authorization Code</p>
<code>{searchParams["code"]}</code>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,140 @@
import { createSignal, Show } from "solid-js";
import { useLocation, useSearchParams } from "@solidjs/router";
import { getAtk, useUserinfo } from "../../stores/userinfo.tsx";
export default function OauthConnectPage() {
const [title, setTitle] = createSignal("Connect Third-party");
const [subtitle, setSubtitle] = createSignal("Via your Goatpass account");
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Handshaking...");
const [loading, setLoading] = createSignal(true);
const [client, setClient] = createSignal<any>(null);
const [searchParams] = useSearchParams();
const userinfo = useUserinfo();
const location = useLocation();
async function preConnect() {
const res = await fetch(`/api/auth/oauth/connect${location.search}`, {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
if (data["session"]) {
setStatus("Redirecting...");
redirect(data["session"]);
} else {
setTitle(`Connect ${data["client"].name}`);
setSubtitle(`Via ${userinfo?.displayName}`);
setClient(data["client"]);
setLoading(false);
}
}
}
function decline() {
if (window.history.length > 0) {
window.history.back();
} else {
window.close();
}
}
async function approve() {
setLoading(true);
setStatus("Approving...");
const res = await fetch("/api/auth/oauth/connect?" + new URLSearchParams({
client_id: searchParams["client_id"] as string,
redirect_uri: encodeURIComponent(searchParams["redirect_uri"] as string),
response_type: "code",
scope: searchParams["scope"] as string
}), {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
setLoading(false);
} else {
const data = await res.json();
setStatus("Redirecting...");
setTimeout(() => redirect(data["session"]), 1850);
}
}
function redirect(session: any) {
const url = `${searchParams["redirect_uri"]}?code=${session["grant_token"]}&state=${searchParams["state"]}`;
window.open(url, "_self");
}
preConnect();
return (
<div class="w-full h-full flex justify-center items-center">
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<Show when={loading()}>
<div class="py-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
</Show>
<Show when={!loading()}>
<div class="mb-3">
<h2 class="font-bold">About who you connecting to</h2>
<p>{client().description}</p>
</div>
<div class="mb-3">
<h2 class="font-bold">Make sure you trust them</h2>
<p>You may share your personal information after connect them. Learn about their privacy policy and user
agreement to keep your personal information in safe.</p>
</div>
<div class="mb-5">
<h2 class="font-bold">After approve this request</h2>
<p>
You will be redirect to{" "}
<span class="link link-primary cursor-not-allowed">{searchParams["redirect_uri"]}</span>
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2">
<button class="btn btn-accent" onClick={() => decline()}>Decline</button>
<button class="btn btn-primary" onClick={() => approve()}>Approve</button>
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
import { readProfiles } from "../../stores/userinfo.tsx";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import Cookie from "universal-cookie";
export default function LoginPage() {
const [title, setTitle] = createSignal("Sign in");
const [subtitle, setSubtitle] = createSignal("Via your Goatpass account");
const [error, setError] = createSignal<null | string>(null);
const [loading, setLoading] = createSignal(false);
const [factor, setFactor] = createSignal<number>();
const [factors, setFactors] = createSignal<any[]>([]);
const [challenge, setChallenge] = createSignal<any>();
const [stage, setStage] = createSignal("starting");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handlers: { [id: string]: any } = {
"starting": async (evt: SubmitEvent) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.id) return;
setLoading(true);
const res = await fetch("/api/auth", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setTitle(`Welcome, ${data["display_name"]}`);
setSubtitle("Before continue, we need verify that's you");
setFactors(data["factors"]);
setChallenge(data["challenge"]);
setError(null);
setStage("choosing");
}
setLoading(false);
},
"choosing": async (evt: SubmitEvent) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.factor) return;
setLoading(true);
const res = await fetch(`/api/auth/factors/${data.factor}`, {
method: "POST"
});
if (res.status !== 200 && res.status !== 204) {
setError(await res.text());
} else {
setTitle(`Enter the code`);
setSubtitle(res.status === 204 ? "Enter your credentials" : "Code has been sent to your inbox");
setError(null);
setFactor(parseInt(data.factor as string));
setStage("verifying");
}
setLoading(false);
},
"verifying": async (evt: SubmitEvent) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.credentials) return;
setLoading(true);
const res = await fetch(`/api/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id: challenge().id,
factor_id: factor(),
secret: data.credentials
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
if (data["is_finished"]) {
await grantToken(data["session"]["grant_token"]);
await readProfiles();
navigate(searchParams["redirect_uri"] ?? "/");
} else {
setError(null);
setStage("choosing");
setTitle("Continue verifying");
setSubtitle("You passed one check, but that's not enough.");
setChallenge(data["challenge"]);
}
}
setLoading(false);
}
};
async function grantToken(tk: string) {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: tk,
grant_type: "grant_token"
})
});
if (res.status !== 200) {
const err = await res.text();
setError(err);
throw new Error(err);
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], { path: "/" });
new Cookie().set("refresh_token", data["refresh_token"], { path: "/" });
setError(null);
}
}
function getFactorAvailable(factor: any) {
const blacklist: number[] = challenge()?.blacklist_factors ?? [];
return blacklist.includes(factor.id);
}
function getFactorName(factor: any) {
switch (factor.type) {
case 0:
return "Password Verification";
case 1:
return "Email Verification Code";
default:
return "Unknown";
}
}
return (
<div class="w-full h-full flex justify-center items-center">
<div>
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<form id="form" onSubmit={(e) => handlers[stage()](e)}>
<Switch>
<Match when={stage() === "starting"}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Account ID</span>
</div>
<input name="id" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Your username, email or phone number.</span>
</div>
</label>
</Match>
<Match when={stage() === "choosing"}>
<div class="join join-vertical w-full">
<For each={factors()}>
{item =>
<input class="join-item btn" type="radio" name="factor"
value={item.id}
disabled={getFactorAvailable(item)}
aria-label={getFactorName(item)}
/>
}
</For>
</div>
<p class="text-center text-sm mt-2">Choose a way to verify that's you</p>
</Match>
<Match when={stage() === "verifying"}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Credentials</span>
</div>
<input name="credentials" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Password or one time password.</span>
</div>
</label>
</Match>
</Switch>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</div>
</div>
<Show when={searchParams["redirect_uri"]}>
<div id="redirect-info" class="mt-3">
<div role="alert" class="alert shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="stroke-info shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>You need to login before access that.</span>
</div>
</div>
</Show>
<div class="text-sm text-center mt-3">
<a target="_blank" href="/auth/register?closable=yes" class="link">Haven't an account? Click here to create
one!</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
import { createSignal, Show } from "solid-js";
import { useWellKnown } from "../../stores/wellKnown.tsx";
import { useNavigate, useSearchParams } from "@solidjs/router";
export default function RegisterPage() {
const [title, setTitle] = createSignal("Create an account");
const [subtitle, setSubtitle] = createSignal("The first step to join our community.");
const [error, setError] = createSignal<null | string>(null);
const [loading, setLoading] = createSignal(false);
const [done, setDone] = createSignal(false);
const [searchParams] = useSearchParams()
const metadata = useWellKnown();
const navigate = useNavigate();
async function submit(evt: SubmitEvent) {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.name || !data.nick || !data.email || !data.password) return;
setLoading(true);
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setTitle("Congratulations!");
setSubtitle("Your account has been created and activation email has sent to your inbox!");
setDone(true);
}
setLoading(false);
}
function callback() {
if(searchParams["closable"]) {
window.close()
} else {
navigate("/auth/login")
}
}
return (
<div class="w-full h-full flex justify-center items-center">
<div>
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
<Show when={!done()}>
<form id="form" onSubmit={submit}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
<span class="label-text-alt font-bold">Cannot be modify</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nickname</span>
</div>
<input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Maximum length is 24 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Email Address</span>
</div>
<input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Do not accept address with plus sign</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Password</span>
</div>
<input name="password" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Must be secure</span>
</div>
</label>
<Show when={!metadata?.open_registration}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Magic Token</span>
</div>
<input name="magic_token" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
This server enabled invitation only, so you need a magic token.
</span>
</div>
</label>
</Show>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</Show>
<Show when={done()}>
<div class="py-12 text-center">
<h2 class="text-lg font-bold">What's next?</h2>
<span>
<a onClick={() => callback()} class="link">Go login</a>{" "}
then you can take part in the entire smartsheep community.
</span>
</div>
</Show>
</div>
</div>
<div class="text-sm text-center mt-3">
<a onClick={() => callback()} class="link">Already had an account? Login now!</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,248 @@
import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, For, Show } from "solid-js";
export default function DashboardPage() {
const userinfo = useUserinfo();
function getGreeting() {
const currentHour = new Date().getHours();
if (currentHour >= 0 && currentHour < 12) {
return "Good morning! Wishing you a day filled with joy and success. ☀️";
} else if (currentHour >= 12 && currentHour < 18) {
return "Afternoon! Hope you have a productive and joyful afternoon! ☀️";
} else {
return "Good evening! Wishing you a relaxing and pleasant evening. 🌙";
}
}
const [events, setEvents] = createSignal<any[]>([]);
const [eventCount, setEventCount] = createSignal(0);
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
async function readEvents() {
const res = await fetch("/api/users/me/events?take=10", {
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setEvents(data["data"]);
setEventCount(data["count"]);
}
}
async function killSession(item: any) {
setSubmitting(true);
const res = await fetch(`/api/users/me/sessions/${item.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readProfiles();
setError(null);
}
setSubmitting(false);
}
readEvents();
return (
<div class="max-w-[720px] mx-auto px-5 pt-12">
<div id="greeting" class="px-5">
<h1 class="text-2xl font-bold">Welcome, {userinfo?.displayName}</h1>
<p>{getGreeting()}</p>
</div>
<div id="alerts">
<Show when={!userinfo?.meta?.confirmed_at}>
<div role="alert" class="alert alert-warning mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span>Your account isn't confirmed yet. Please check your inbox and confirm your account.</span> <br />
<span>Otherwise your account will be deactivate after 48 hours.</span>
</div>
</div>
</Show>
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div id="overview" class="mt-5">
<div class="stats shadow w-full">
<div class="stat">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="stat-title">Challenges</div>
<div class="stat-value">{userinfo?.meta?.challenges?.length}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg>
</div>
<div class="stat-title">Sessions</div>
<div class="stat-value">{userinfo?.meta?.sessions?.length}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
</div>
<div class="stat-title">Events</div>
<div class="stat-value">{eventCount()}</div>
</div>
</div>
</div>
<div id="data-area" class="mt-5 shadow">
<div class="join join-vertical w-full">
<details class="collapse collapse-plus join-item border border-base-300">
<summary class="collapse-title text-lg font-medium">
Challenges
</summary>
<div class="collapse-content mx-[-16px]">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>State</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={userinfo?.meta?.challenges ?? []}>
{item => <tr>
<th>{item.id}</th>
<td>{item.state}</td>
<td>{item.ip_address}</td>
<td>
<span class="tooltip" data-tip={item.user_agent}>
{item.user_agent.substring(0, 10) + "..."}
</span>
</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
</tr>}
</For>
</tbody>
</table>
</div>
</div>
</details>
<details class="collapse collapse-plus join-item border border-base-300">
<summary class="collapse-title text-lg font-medium">
Sessions
</summary>
<div class="collapse-content mx-[-16px]">
<table class="table">
<thead>
<tr>
<th></th>
<th>Third Client</th>
<th>Audiences</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={userinfo?.meta?.sessions ?? []}>
{item => <tr>
<th>{item.id}</th>
<td>{item.client_id ? "Linked" : "Non-linked"}</td>
<td>{item.audiences?.join(", ")}</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
<td class="py-0">
<button class="btn btn-sm btn-square btn-error" disabled={submitting()}
onClick={() => killSession(item)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-5 w-5">
<path
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208s208-93.31 208-208S370.69 48 256 48zm80 224H176a16 16 0 0 1 0-32h160a16 16 0 0 1 0 32z"
fill="currentColor"></path>
</svg>
</button>
</td>
</tr>}
</For>
</tbody>
</table>
</div>
</details>
<details class="collapse collapse-plus join-item border border-base-300">
<summary class="collapse-title text-lg font-medium">
Events
</summary>
<div class="collapse-content mx-[-16px]">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>Type</th>
<th>Target</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={events()}>
{item => <tr>
<th>{item.id}</th>
<td>{item.type}</td>
<td>{item.target}</td>
<td>{item.ip_address}</td>
<td>
<span class="tooltip" data-tip={item.user_agent}>
{item.user_agent.substring(0, 10) + "..."}
</span>
</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
</tr>}
</For>
</tbody>
</table>
</div>
</div>
</details>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { createSignal, Show } from "solid-js";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { readProfiles } from "../../stores/userinfo.tsx";
export default function ConfirmRegistrationPage() {
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Confirming your account...");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
async function doConfirm() {
if (!searchParams["tk"]) {
setError("Bad Request: Code was not exists");
}
const res = await fetch("/api/users/me/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: searchParams["tk"]
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
setStatus("Confirmed. Redirecting to dashboard...");
await readProfiles();
navigate("/");
}
}
doConfirm();
return (
<div class="w-full h-full flex justify-center items-center">
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">Confirm your account</h1>
<p>Hold on, we are working on it. Almost finished.</p>
</div>
<div class="pt-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
<Show when={error()} fallback={<div class="mt-16"></div>}>
<div id="alerts" class="mt-16">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
meta: any
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null,
meta: null
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("access_token");
}
export async function refreshAtk() {
const rtk = new Cookie().get("refresh_token");
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: rtk,
grant_type: "refresh_token"
})
});
if (res.status !== 200) {
console.error(await res.text())
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], { path: "/" });
new Cookie().set("refresh_token", data["refresh_token"], { path: "/" });
}
}
function checkLoggedIn(): boolean {
return new Cookie().get("access_token");
}
export async function readProfiles(recovering = true) {
if (!checkLoggedIn()) return;
const res = await fetch("/api/users/me", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
if (recovering) {
// Auto retry after refresh access token
await refreshAtk();
return await readProfiles(false);
} else {
clearUserinfo();
window.location.reload();
}
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: data
});
}
export function clearUserinfo() {
new Cookie().remove("access_token", { path: "/" });
new Cookie().remove("refresh_token", { path: "/" });
setUserinfo(defaultUserinfo);
}
export function UserinfoProvider(props: any) {
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
}

View File

@ -0,0 +1,23 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await fetch("/.well-known")
setWellKnown(await res.json())
}
export function WellKnownProvider(props: any) {
return (
<WellKnownContext.Provider value={wellKnown}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

1
pkg/view/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,44 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}"
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
},
{
dark: {
...require("daisyui/src/theming/themes")["dark"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
}
]
},
plugins: [require("daisyui")]
};

26
pkg/view/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
pkg/view/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
export default defineConfig({
plugins: [devtools({ autoname: true }), solid()],
server: {
proxy: {
"/api": "http://localhost:8444",
"/.well-known": "http://localhost:8444"
}
}
});