Initial commit
This commit is contained in:
58 files changed
+5041
No files matched your search
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
dist
|
||||
.wrangler
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.vinxi
|
||||
app.config.timestamp_*.js
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# Temp
|
||||
gitignore
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -0,0 +1,12 @@
|
||||
# Staff-Auth-Frontend
|
||||
The Solid.JS frontend for [Staff-Auth](https://github.com/true-og/Staff-Auth).
|
||||
|
||||
## Building
|
||||
1. `pnpn install`
|
||||
2. `pnpm build`
|
||||
|
||||
## Serve
|
||||
`pnpm serve`
|
||||
|
||||
## Configuring
|
||||
Copy [.env.example](.env.example), rename it to `.env` and fill it in.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/webp" href="/favicon.webp" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Staff-Auth</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "vite-template-solid",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"daisyui": "^5.5.19",
|
||||
"postcss": "^8.4.49",
|
||||
"solid-devtools": "^0.34.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.1.4",
|
||||
"vite-plugin-solid": "^2.11.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solid-primitives/i18n": "^2.2.1",
|
||||
"@solidjs/router": "^0.15.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"solid-icons": "^1.2.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.2.1+sha512.398035c7bd696d0ba0b10a688ed558285329d27ea994804a52bad9167d8e3a72bcb993f9699585d3ca25779ac64949ef422757a6c31102c12ab932e5cbe5cc92",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+2126
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
+13
@@ -0,0 +1,13 @@
|
||||
import { JSX, Suspense, type Component } from "solid-js";
|
||||
|
||||
const App: Component<{ children: JSX.Element }> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<main class="flex-1">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { FiMenu, FiHome, FiUsers, FiUserPlus, FiLogOut } from "solid-icons/fi";
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
ParentProps,
|
||||
Show,
|
||||
Suspense,
|
||||
} from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { BASE_URL } from "../lib/api";
|
||||
import { useUser } from "../store/auth";
|
||||
import { dict, t } from "../lib/i18n";
|
||||
import { AppError } from "../types/api";
|
||||
import Loader from "./Loader";
|
||||
import Error from "./Error";
|
||||
import { throwIfFatal } from "../lib/error";
|
||||
|
||||
function App(props: ParentProps) {
|
||||
const { user, error } = useUser();
|
||||
const [isDrawerOpen, setDrawerOpen] = createSignal(false);
|
||||
const toggleDrawer = () => {
|
||||
if (window.matchMedia("(max-width: 64rem)").matches)
|
||||
setDrawerOpen(!isDrawerOpen());
|
||||
};
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const err = error();
|
||||
if (err) {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
const u = user();
|
||||
if (u) {
|
||||
if (!u["roles"].includes("ADMIN")) {
|
||||
throw new AppError("fatal", t("panel.error.unauthorized"), null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input
|
||||
id="drawer"
|
||||
type="checkbox"
|
||||
class="drawer-toggle"
|
||||
checked={isDrawerOpen()}
|
||||
ref={(el) => {
|
||||
el.addEventListener("change", () => setDrawerOpen(el.checked));
|
||||
}}
|
||||
/>
|
||||
<div class="drawer-content flex flex-col">
|
||||
<nav class="navbar items-center gap-4 lg:hidden">
|
||||
<label for="drawer" class="btn btn-ghost drawer-button">
|
||||
<FiMenu class="text-xl" />
|
||||
</label>
|
||||
<span>Staff-Auth</span>
|
||||
</nav>
|
||||
{props.children}
|
||||
</div>
|
||||
<div class="drawer-side">
|
||||
<label
|
||||
for="drawer"
|
||||
aria-label="close sidebar"
|
||||
class="drawer-overlay"
|
||||
></label>
|
||||
<ul class="menu menu-lg bg-base-200 min-h-full gap-1 w-60">
|
||||
<li class="menu-title">Staff-Auth</li>
|
||||
<li>
|
||||
<A onClick={toggleDrawer} href="/" activeClass="menu-active" end>
|
||||
<FiHome class="text-lg" />
|
||||
{t("panel.menu.home")}
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<A
|
||||
onClick={toggleDrawer}
|
||||
href="/users"
|
||||
activeClass="menu-active"
|
||||
end
|
||||
>
|
||||
<FiUsers class="text-lg" />
|
||||
{t("panel.menu.users")}
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<A
|
||||
onClick={toggleDrawer}
|
||||
href="/invites"
|
||||
activeClass="menu-active"
|
||||
end
|
||||
>
|
||||
<FiUserPlus class="text-lg" />
|
||||
{t("panel.menu.invites")}
|
||||
</A>
|
||||
</li>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Show when={user()}>
|
||||
{(u) => (
|
||||
<li class="dropdown dropdown-top dropdown-center w-full">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src={u()["picture"]}
|
||||
alt="Picture"
|
||||
class="w-6 h-6 rounded"
|
||||
></img>
|
||||
<span>{u()["name"]}</span>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-1 w-full p-2 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
onClick={() =>
|
||||
(window.location.href = `${BASE_URL}/logout`)
|
||||
}
|
||||
class="text-red-400"
|
||||
>
|
||||
<FiLogOut class="text-lg" />
|
||||
{t("panel.menu.logOut")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<Show when={dict()}>
|
||||
<ErrorBoundary
|
||||
fallback={(e, reset) => {
|
||||
let text;
|
||||
let canReset = true;
|
||||
if (e instanceof TypeError) {
|
||||
text = t("error.networkError");
|
||||
} else if (e instanceof AppError) {
|
||||
text = e.message;
|
||||
canReset = e.kind !== "fatal";
|
||||
} else {
|
||||
text = t("error.unknownError");
|
||||
}
|
||||
|
||||
return (
|
||||
<Error
|
||||
text={text}
|
||||
fillScreen={true}
|
||||
reset={canReset ? reset : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<Suspense
|
||||
fallback={<Loader text={t("panel.loading")} fillScreen={true} />}
|
||||
>
|
||||
<App {...props} />
|
||||
</Suspense>
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ErrorBoundary, ParentProps, Show } from "solid-js";
|
||||
import { AppError } from "../types/api";
|
||||
import { dict, t } from "../lib/i18n";
|
||||
import Error from "./Error";
|
||||
|
||||
export default function BasicLayout(props: ParentProps) {
|
||||
return (
|
||||
<Show when={dict()}>
|
||||
<ErrorBoundary
|
||||
fallback={(e, reset) => {
|
||||
let text;
|
||||
let canReset = true;
|
||||
if (e instanceof TypeError) {
|
||||
text = t("error.networkError");
|
||||
} else if (e instanceof AppError) {
|
||||
text = e.message;
|
||||
canReset = e.kind !== "fatal";
|
||||
} else {
|
||||
text = t("error.unknownError");
|
||||
}
|
||||
|
||||
return (
|
||||
<Error
|
||||
text={text}
|
||||
fillScreen={true}
|
||||
reset={canReset ? reset : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FiXCircle } from "solid-icons/fi";
|
||||
import { t } from "../lib/i18n";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
export default function ErrorComponent({
|
||||
text,
|
||||
fillScreen,
|
||||
reset,
|
||||
}: {
|
||||
text: string;
|
||||
fillScreen: boolean;
|
||||
reset?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`flex flex-col ${fillScreen ? "min-h-screen" : ""} items-center justify-center`}
|
||||
>
|
||||
<FiXCircle class="h-16 w-full mb-4" />
|
||||
<p class="text-lg text-center">{text}</p>
|
||||
<Show when={reset}>
|
||||
{(reset) => (
|
||||
<button onClick={() => reset()()} class="btn btn-soft mt-4">
|
||||
{t("panel.tryAgain")}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function LoaderComponent({
|
||||
text,
|
||||
fillScreen,
|
||||
}: {
|
||||
text: string;
|
||||
fillScreen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`flex flex-col ${fillScreen ? "min-h-screen" : ""} items-center justify-center gap-6`}
|
||||
>
|
||||
<span class="loading loading-spinner w-12"></span>
|
||||
<p class="text-lg text-center">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { loginApi } from "../../lib/login";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
export default function CredentialsComponent({
|
||||
loginChallenge,
|
||||
done,
|
||||
}: {
|
||||
loginChallenge: string;
|
||||
done: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await loginApi.credentials(
|
||||
{
|
||||
username: username(),
|
||||
password: password(),
|
||||
},
|
||||
loginChallenge,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
done();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<h1 class="text-2xl text-center font-bold">
|
||||
{t("login.credentials.title")}
|
||||
</h1>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("login.credentials.username")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder={t("login.credentials.username")}
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label class="label">{t("login.credentials.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder={t("login.credentials.password")}
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button type="submit" class="btn btn-primary mt-4">
|
||||
{t("login.continue")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createSignal, onMount } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { loginApi } from "../../lib/login";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
export default function MinecraftCheckComponent({
|
||||
loginChallenge,
|
||||
done,
|
||||
}: {
|
||||
loginChallenge: string;
|
||||
done: () => void;
|
||||
}) {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function check() {
|
||||
let valid;
|
||||
try {
|
||||
valid = await loginApi.minecraftCheck(loginChallenge);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
if (!valid) {
|
||||
setError("Check failed");
|
||||
return;
|
||||
}
|
||||
done();
|
||||
}
|
||||
|
||||
onMount(check);
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<h1 class="text-2xl text-center font-bold">
|
||||
{t("minecraftCheck.title")}
|
||||
</h1>
|
||||
{!error() && <span class="loading loading-ring w-24"></span>}
|
||||
{!error() && <p class="text-lg">{t("minecraftCheck.logIn")}</p>}
|
||||
{error() && <p class="text-error">{error()}</p>}
|
||||
{error() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
check();
|
||||
}}
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { loginApi } from "../../lib/login";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
export default function TotpComponent({
|
||||
loginChallenge,
|
||||
}: {
|
||||
loginChallenge: string;
|
||||
}) {
|
||||
const [code, setCode] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function submit() {
|
||||
let redirectUrl;
|
||||
try {
|
||||
redirectUrl = await loginApi.totp(code(), loginChallenge);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
window.location.href = redirectUrl!;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<h1 class="text-2xl text-center font-bold">{t("totp.title")}</h1>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("totp.code")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input validator w-full"
|
||||
placeholder={t("totp.code")}
|
||||
value={code()}
|
||||
onInput={(e) => setCode(e.target.value)}
|
||||
required
|
||||
pattern="\d{6}"
|
||||
title={t("totp.error.enterValidCode")}
|
||||
/>
|
||||
<div class="validator-hint hidden">
|
||||
{t("totp.error.enterValidCode")}
|
||||
</div>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button type="submit" class="btn btn-primary mt-4">
|
||||
{t("login.continue")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { AppError } from "../../types/api";
|
||||
import { setupApi } from "../../lib/setup";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
export default function DetailsComponent({
|
||||
token,
|
||||
done,
|
||||
}: {
|
||||
token: string;
|
||||
done: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await setupApi.details({ email: email(), password: password() }, token);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
done();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<h1 class="text-2xl text-center font-bold">
|
||||
{t("setup.details.title")}
|
||||
</h1>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("setup.details.email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder={t("setup.details.email")}
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label class="label">{t("setup.details.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder={t("setup.details.password")}
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button type="submit" class="btn btn-primary mt-4">
|
||||
{t("setup.continue")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createSignal, onMount } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { setupApi } from "../../lib/setup";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
export default function MinecraftCheckComponent({
|
||||
token,
|
||||
done,
|
||||
}: {
|
||||
token: string;
|
||||
done: () => void;
|
||||
}) {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function check() {
|
||||
let valid;
|
||||
try {
|
||||
valid = await setupApi.minecraftCheck(token);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
if (!valid) {
|
||||
setError("Check failed");
|
||||
return;
|
||||
}
|
||||
done();
|
||||
}
|
||||
|
||||
onMount(check);
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<h1 class="text-2xl text-center font-bold">
|
||||
{t("minecraftCheck.title")}
|
||||
</h1>
|
||||
{!error() && <span class="loading loading-ring w-24"></span>}
|
||||
{!error() && <p class="text-lg">{t("minecraftCheck.logIn")}</p>}
|
||||
{error() && <p class="text-error">{error()}</p>}
|
||||
{error() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
check();
|
||||
}}
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createAsync, useNavigate } from "@solidjs/router";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { setupApi } from "../../lib/setup";
|
||||
import { createSignal, Suspense } from "solid-js";
|
||||
import Loader from "../Loader";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
function TotpSetup({ token }: { token: string }) {
|
||||
const navigate = useNavigate();
|
||||
const totpData = createAsync(() => setupApi.totpSetup(token));
|
||||
const [code, setCode] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await setupApi.totpVerify(code(), token);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
navigate("/successful-setup");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<h2 class="text-lg text-center">
|
||||
{t("setup.totpSetup.addToAuthenticator")}
|
||||
</h2>
|
||||
<img
|
||||
class="w-72"
|
||||
src={totpData()?.qrCode}
|
||||
alt={t("setup.totpSetup.totpQrCode")}
|
||||
></img>
|
||||
<div class="collapse bg-base-100 border border-base-300">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title font-semibold">
|
||||
{t("setup.totpSetup.showSecret")}
|
||||
</div>
|
||||
<div class="collapse-content text-sm">{totpData()?.secret}</div>
|
||||
</div>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("totp.code")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input validator w-full"
|
||||
placeholder={t("totp.code")}
|
||||
value={code()}
|
||||
onInput={(e) => setCode(e.target.value)}
|
||||
required
|
||||
pattern="\d{6}"
|
||||
title={t("totp.error.enterValidCode")}
|
||||
/>
|
||||
<div class="validator-hint hidden">
|
||||
{t("totp.error.enterValidCode")}
|
||||
</div>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button type="submit" class="btn btn-primary mt-4">
|
||||
{t("setup.continue")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TotpSetupComponent({
|
||||
token,
|
||||
done,
|
||||
}: {
|
||||
token: string;
|
||||
done: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader text={t("setup.totpSetup.loading")} fillScreen={false} />
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<h1 class="text-2xl text-center font-bold">
|
||||
{t("setup.totpSetup.title")}
|
||||
</h1>
|
||||
<TotpSetup token={token} />
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FiXCircle } from "solid-icons/fi";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div class="flex flex-col min-h-screen items-center justify-center">
|
||||
<FiXCircle class="h-16 w-full mb-8" />
|
||||
<p class="text-xl text-center">404 - Not Found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
export const dict = {
|
||||
login: {
|
||||
continue: "Continue",
|
||||
loading: "Loading login...",
|
||||
credentials: {
|
||||
title: "Login",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
error: {
|
||||
incorrectUsernameOrPassword: "Incorrect username or password",
|
||||
},
|
||||
},
|
||||
error: {
|
||||
noChallenge: "No login challenge was provided",
|
||||
invalidChallenge: "An invalid login challenge was provided",
|
||||
challengeUsed: "This login challenge has already been used",
|
||||
},
|
||||
},
|
||||
totp: {
|
||||
title: "TOTP",
|
||||
code: "Code",
|
||||
error: {
|
||||
enterValidCode: "Enter a valid 6-digit TOTP code",
|
||||
invalidCode: "Incorrect TOTP code",
|
||||
},
|
||||
},
|
||||
minecraftCheck: {
|
||||
title: "Minecraft Check",
|
||||
logIn: "Please log in...",
|
||||
error: {
|
||||
timeout: "You took too long to log in",
|
||||
unavailable: "The Minecraft check is currently unavailable",
|
||||
},
|
||||
},
|
||||
consent: {
|
||||
title: "Consent",
|
||||
loading: "Loading consent...",
|
||||
clientWouldLikeTo: "{{ client }} would like to:",
|
||||
scopes: {
|
||||
emailRead: "Read your email address",
|
||||
profileRead: "Read your profile",
|
||||
rolesRead: "Read your roles",
|
||||
default: "Read the {{ scope }} scope",
|
||||
},
|
||||
allow: "Allow",
|
||||
deny: "Deny",
|
||||
error: {
|
||||
noChallenge: "No consent challenge was provided",
|
||||
invalidChallenge: "An invalid consent challenge was provided",
|
||||
challengeUsed: "This consent challenge has already been used",
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
loading: "Loading panel...",
|
||||
welcome: "Welcome to Staff-Auth",
|
||||
tryAgain: "Try again",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
menu: {
|
||||
home: "Home",
|
||||
users: "Users",
|
||||
invites: "Invites",
|
||||
logOut: "Log out",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
username: "Username",
|
||||
email: "Email",
|
||||
minecraftUuid: "Minecraft UUID",
|
||||
role: "Role",
|
||||
setUp: "Set Up",
|
||||
change: "Change",
|
||||
create: "Create",
|
||||
createNew: "Create new user",
|
||||
update: "Update",
|
||||
updated: "Updated",
|
||||
delete: "Delete",
|
||||
deactivate: "Deactivate",
|
||||
deactivated: "Deactivated",
|
||||
reactivate: "Reactivate",
|
||||
reactivated: "Reactivated",
|
||||
loadingUser: "Loading user...",
|
||||
loadingUsers: "Loading users...",
|
||||
cannotDeactivateSelf: "You cannot deactivate yourself",
|
||||
roles: {
|
||||
pick: "Pick a role",
|
||||
helper: "Helper",
|
||||
moderator: "Moderator",
|
||||
admin: "Admin",
|
||||
},
|
||||
error: {
|
||||
enterValidUuid: "Enter a valid UUID",
|
||||
failedToLoadUsers: "Failed to load users",
|
||||
failedToLoadUser: "Failed to load user",
|
||||
deactivateSelf: "You cannot deactivate yourself",
|
||||
duplicateMinecraftUuid:
|
||||
"A user already exists with that Minecraft UUID",
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
title: "Invites",
|
||||
link: "Link",
|
||||
user: "User",
|
||||
loading: "Loading invites...",
|
||||
pickUser: "Pick a user",
|
||||
createNew: "Create new invite",
|
||||
delete: "Delete",
|
||||
create: "Create",
|
||||
copy: "Copy",
|
||||
copied: "Copied!",
|
||||
error: {
|
||||
failedToLoad: "Failed to load invites",
|
||||
userAlreadySetUp: "User has already been set up",
|
||||
duplicate: "An invite already exists for that user",
|
||||
},
|
||||
},
|
||||
error: {
|
||||
couldNotUpdate: "Could not update",
|
||||
couldNotDelete: "Could not delete",
|
||||
couldNotCreate: "Could not create",
|
||||
unauthorized: "You are not authorized to access the panel",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
loading: "Loading setup...",
|
||||
continue: "Continue",
|
||||
success: "Successfully set up your account",
|
||||
details: {
|
||||
title: "Set Details",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
},
|
||||
totpSetup: {
|
||||
title: "TOTP Setup",
|
||||
loading: "Loading TOTP setup...",
|
||||
addToAuthenticator:
|
||||
"Add the following account to your authenticator app:",
|
||||
totpQrCode: "TOTP QR code",
|
||||
showSecret: "Show secret",
|
||||
},
|
||||
error: {
|
||||
noToken: "No invite token was provided",
|
||||
finalized: "Setup was already finalized",
|
||||
invalidToken: "Invalid invite token",
|
||||
deactivated: "User deactivated",
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
loggedOut: "Logged out",
|
||||
logBackIn: "Log back in",
|
||||
},
|
||||
error: {
|
||||
unknownError: "Unknown error",
|
||||
networkError: "Network error",
|
||||
tooManyRequests: "Too many requests",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
/*@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}*/
|
||||
@@ -0,0 +1,22 @@
|
||||
/* @refresh reload */
|
||||
import "solid-devtools";
|
||||
import "./index.css";
|
||||
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import App from "./app";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { routes } from "./routes";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => <Router root={(props) => <App>{props.children}</App>}>{routes}</Router>,
|
||||
root!,
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiError, AppError, MeData } from "../types/api";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
if (!BASE_URL) throw new Error("VITE_API_URL not set");
|
||||
|
||||
export const LOGIN_URL = `${BASE_URL}/oauth/login/staffauth`;
|
||||
|
||||
export const api = {
|
||||
me: async (): Promise<MeData> => {
|
||||
const response = await fetch(`${BASE_URL}/me`, { credentials: "include" });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
};
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
throw new AppError("fatal", getApiErrorMessage(err), err.status);
|
||||
}
|
||||
throw new AppError("fatal", "error.unknownError", null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
if (err.status === 429) {
|
||||
return t("error.tooManyRequests");
|
||||
}
|
||||
switch (err.message) {
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(err: unknown): err is ApiError {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"status" in err &&
|
||||
"message" in err
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiError, AppError } from "../types/api";
|
||||
import { ConsentData } from "../types/consent";
|
||||
import { BASE_URL, isApiError } from "./api";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const consentApi = {
|
||||
consentData: async (consentChallenge: string): Promise<ConsentData> => {
|
||||
const url = new URL("/consent", BASE_URL);
|
||||
url.searchParams.set("consent_challenge", consentChallenge);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns redirect URL
|
||||
*/
|
||||
consent: async (
|
||||
consent: boolean,
|
||||
consentChallenge: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${BASE_URL}/consent`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
consent: consent,
|
||||
consentChallenge: consentChallenge,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return await response.text();
|
||||
},
|
||||
};
|
||||
|
||||
const fatal = ["INVALID_CONSENT_CHALLENGE", "CONSENT_REQUEST_USED"];
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
throw new AppError(
|
||||
fatal.includes(err.message) ? "fatal" : "local",
|
||||
getApiErrorMessage(err),
|
||||
err.status,
|
||||
);
|
||||
}
|
||||
throw new AppError("fatal", t("error.unknownError"), null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
switch (err.message) {
|
||||
case "INVALID_CONSENT_CHALLENGE":
|
||||
return t("consent.error.invalidChallenge");
|
||||
case "CONSENT_REQUEST_USED":
|
||||
return t("consent.error.challengeUsed");
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
export function throwIfFatal(err: Accessor<Error | null>, clear: () => void) {
|
||||
return () => {
|
||||
const e = err();
|
||||
if (e) {
|
||||
clear();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import * as i18n from "@solid-primitives/i18n";
|
||||
|
||||
/*
|
||||
Assuming the dictionaries are in the following structure:
|
||||
./i18n
|
||||
en.ts
|
||||
fr.ts
|
||||
es.ts
|
||||
And all exports a `dict` object
|
||||
*/
|
||||
|
||||
// use `type` to not include the actual dictionary in the bundle
|
||||
import type * as en from "../i18n/en.ts";
|
||||
|
||||
export type Locale = "en"; // | "nl"
|
||||
export type RawDictionary = typeof en.dict;
|
||||
export type Dictionary = i18n.Flatten<RawDictionary>;
|
||||
|
||||
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
|
||||
const dict: RawDictionary = (await import(`../i18n/${locale}.ts`)).dict;
|
||||
return i18n.flatten(dict); // flatten the dictionary to make all nested keys available top-level
|
||||
}
|
||||
|
||||
export const [locale, setLocale] = createSignal<Locale>("en");
|
||||
export const dict = createAsync(() => fetchDictionary(locale()));
|
||||
export const t = i18n.translator(
|
||||
() => dict() ?? ({} as Dictionary),
|
||||
i18n.resolveTemplate,
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ApiError, AppError } from "../types/api";
|
||||
import { CreateInviteData, InviteData } from "../types/invite";
|
||||
import { BASE_URL, isApiError, LOGIN_URL } from "./api";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const inviteApi = {
|
||||
getAll: async (): Promise<InviteData[]> => {
|
||||
const response = await fetch(`${BASE_URL}/invite`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<InviteData> => {
|
||||
const response = await fetch(`${BASE_URL}/invite/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
create: async (userData: CreateInviteData): Promise<InviteData> => {
|
||||
const response = await fetch(`${BASE_URL}/invite`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @return success
|
||||
*/
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const response = await fetch(`${BASE_URL}/invite/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
throw new AppError("local", getApiErrorMessage(err), err.status);
|
||||
}
|
||||
throw new AppError("fatal", t("error.unknownError"), null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
if (err.status === 429) {
|
||||
return t("error.tooManyRequests");
|
||||
}
|
||||
switch (err.message) {
|
||||
case "USER_ALREADY_SET_UP":
|
||||
return t("panel.invites.error.userAlreadySetUp");
|
||||
case "DUPLICATE_INVITE":
|
||||
return t("panel.invites.error.duplicate");
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ApiError, AppError } from "../types/api";
|
||||
import { BASE_URL, isApiError } from "./api";
|
||||
import { t } from "../lib/i18n";
|
||||
import { LoginStage } from "../types/login";
|
||||
|
||||
export const loginApi = {
|
||||
currentStage: async (loginChallenge: string): Promise<LoginStage> => {
|
||||
const url = new URL("/login/currentStage", BASE_URL);
|
||||
url.searchParams.set("login_challenge", loginChallenge);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
const stage = getStage(await response.text());
|
||||
if (stage instanceof AppError) throw stage;
|
||||
return stage;
|
||||
},
|
||||
|
||||
credentials: async (
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
loginChallenge: string,
|
||||
) => {
|
||||
const response = await fetch(`${BASE_URL}/login/credentials`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...credentials,
|
||||
loginChallenge: loginChallenge,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
},
|
||||
|
||||
minecraftCheck: async (loginChallenge: string): Promise<boolean> => {
|
||||
const response = await fetch(`${BASE_URL}/login/minecraftcheck`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ loginChallenge: loginChallenge }),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return (await response.text()) === "true";
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns redirect URL
|
||||
*/
|
||||
totp: async (code: string, loginChallenge: string): Promise<string> => {
|
||||
const response = await fetch(`${BASE_URL}/login/totp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: code, loginChallenge: loginChallenge }),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return await response.text();
|
||||
},
|
||||
};
|
||||
|
||||
const fatal = ["INVALID_LOGIN_CHALLENGE", "LOGIN_REQUEST_USED"];
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
return new AppError(
|
||||
fatal.includes(err.message) ? "fatal" : "local",
|
||||
getApiErrorMessage(err),
|
||||
err.status,
|
||||
);
|
||||
}
|
||||
return new AppError("fatal", "error.unknownError", null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
if (err.status === 429) {
|
||||
return t("error.tooManyRequests");
|
||||
}
|
||||
switch (err.message) {
|
||||
case "INCORRECT_USERNAME_OR_PASSWORD":
|
||||
return t("login.credentials.error.incorrectUsernameOrPassword");
|
||||
case "MINECRAFT_CHECK_TIMEOUT":
|
||||
return t("minecraftCheck.error.timeout");
|
||||
case "MINECRAFT_CHECK_UNAVAILABLE":
|
||||
return t("minecraftCheck.error.unavailable");
|
||||
case "INCORRECT_TOTP_CODE":
|
||||
return t("totp.error.invalidCode");
|
||||
case "INVALID_LOGIN_CHALLENGE":
|
||||
return t("login.error.invalidChallenge");
|
||||
case "LOGIN_REQUEST_USED":
|
||||
return t("login.error.challengeUsed");
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
|
||||
function getStage(stage: string): LoginStage {
|
||||
switch (stage) {
|
||||
case "CREDENTIALS":
|
||||
return { type: "credentials" };
|
||||
case "MINECRAFT_CHECK":
|
||||
return { type: "minecraftCheck" };
|
||||
case "TOTP":
|
||||
return { type: "totp" };
|
||||
case "ACCEPT":
|
||||
throw new AppError("fatal", t("login.error.challengeUsed"), null);
|
||||
default:
|
||||
throw new AppError("fatal", t("error.unknownError"), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ApiError, AppError } from "../types/api";
|
||||
import { BASE_URL, isApiError } from "./api";
|
||||
import { t } from "../lib/i18n";
|
||||
import { SetupStage, TotpData } from "../types/setup";
|
||||
|
||||
export const setupApi = {
|
||||
currentStage: async (token: string): Promise<SetupStage> => {
|
||||
const url = new URL("/setup/currentStage", BASE_URL);
|
||||
url.searchParams.set("token", token);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
const stage = getStage(await response.text());
|
||||
if (stage instanceof AppError) throw stage;
|
||||
return stage;
|
||||
},
|
||||
|
||||
details: async (
|
||||
details: { email: string; password: string },
|
||||
token: string,
|
||||
) => {
|
||||
const response = await fetch(`${BASE_URL}/setup/details`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...details,
|
||||
token: token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
},
|
||||
|
||||
minecraftCheck: async (token: string): Promise<boolean> => {
|
||||
const response = await fetch(`${BASE_URL}/setup/minecraftcheck`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return (await response.text()) === "true";
|
||||
},
|
||||
|
||||
totpSetup: async (token: string): Promise<TotpData> => {
|
||||
const response = await fetch(`${BASE_URL}/setup/totpsetup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
totpVerify: async (code: string, token: string) => {
|
||||
const response = await fetch(`${BASE_URL}/setup/totpverify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: code, token: token }),
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const fatal = ["INVALID_INVITE", "DEACTIVATED"];
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
return new AppError(
|
||||
fatal.includes(err.message) ? "fatal" : "local",
|
||||
getApiErrorMessage(err),
|
||||
err.status,
|
||||
);
|
||||
}
|
||||
return new AppError("fatal", "error.unknownError", null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
if (err.status === 429) {
|
||||
return t("error.tooManyRequests");
|
||||
}
|
||||
switch (err.message) {
|
||||
case "MINECRAFT_CHECK_TIMEOUT":
|
||||
return t("minecraftCheck.error.timeout");
|
||||
case "MINECRAFT_CHECK_UNAVAILABLE":
|
||||
return t("minecraftCheck.error.unavailable");
|
||||
case "INCORRECT_TOTP_CODE":
|
||||
return t("totp.error.invalidCode");
|
||||
case "INVALID_INVITE":
|
||||
return t("setup.error.invalidToken");
|
||||
case "DEACTIVATED":
|
||||
return t("setup.error.deactivated");
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
|
||||
function getStage(stage: string): SetupStage {
|
||||
switch (stage) {
|
||||
case "DETAILS":
|
||||
return { type: "details" };
|
||||
case "MINECRAFT_CHECK":
|
||||
return { type: "minecraftCheck" };
|
||||
case "TOTP":
|
||||
return { type: "totp" };
|
||||
case "TOTP_VERIFY":
|
||||
return { type: "totpVerify" };
|
||||
case "FINALIZE":
|
||||
throw new AppError("fatal", t("setup.error.finalized"), null);
|
||||
default:
|
||||
throw new AppError("fatal", t("error.unknownError"), null);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { ApiError, AppError } from "../types/api";
|
||||
import { CreateUserData, UpdateUserData, UserData } from "../types/user";
|
||||
import { BASE_URL, isApiError, LOGIN_URL } from "./api";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const userApi = {
|
||||
getAll: async (): Promise<UserData[]> => {
|
||||
const response = await fetch(`${BASE_URL}/user`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<UserData> => {
|
||||
const response = await fetch(`${BASE_URL}/user/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
update: async (userData: UpdateUserData): Promise<UserData> => {
|
||||
const response = await fetch(`${BASE_URL}/user/${userData.id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
create: async (userData: CreateUserData): Promise<UserData> => {
|
||||
const response = await fetch(`${BASE_URL}/user`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
throw toAppError({
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
});
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* @return success
|
||||
*/
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const response = await fetch(`${BASE_URL}/user/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = LOGIN_URL;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function toAppError(err: unknown): AppError {
|
||||
if (isApiError(err)) {
|
||||
throw new AppError("local", getApiErrorMessage(err), err.status);
|
||||
}
|
||||
throw new AppError("fatal", t("error.unknownError"), null);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(err: ApiError): string {
|
||||
if (err.status === 429) {
|
||||
return t("error.tooManyRequests");
|
||||
}
|
||||
switch (err.message) {
|
||||
case "DUPLICATE_MINECRAFT_UUID":
|
||||
return t("panel.users.error.duplicateMinecraftUuid");
|
||||
case "DEACTIVATE_SELF":
|
||||
return t("panel.users.error.deactivateSelf");
|
||||
default:
|
||||
return t("error.unknownError");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { AppError } from "../types/api";
|
||||
import { t } from "../lib/i18n";
|
||||
import { consentApi } from "../lib/consent";
|
||||
|
||||
export const getConsentData = query(
|
||||
async (consentChallenge: string | string[] | undefined) => {
|
||||
if (!consentChallenge)
|
||||
throw new AppError("fatal", t("consent.error.noChallenge"), null);
|
||||
if (typeof consentChallenge !== "string")
|
||||
throw new AppError("fatal", t("consent.error.invalidChallenge"), null);
|
||||
return await consentApi.consentData(consentChallenge);
|
||||
},
|
||||
"consentData",
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { For, Suspense } from "solid-js";
|
||||
import { t } from "../lib/i18n";
|
||||
import { createAsync, useSearchParams } from "@solidjs/router";
|
||||
import { consentApi } from "../lib/consent";
|
||||
import { getConsentData } from "./consent.data";
|
||||
import Loader from "../components/Loader";
|
||||
|
||||
const scopeMap = new Map<string, () => string>([
|
||||
["email", () => t("consent.scopes.emailRead")],
|
||||
["profile", () => t("consent.scopes.profileRead")],
|
||||
["roles", () => t("consent.scopes.rolesRead")],
|
||||
]);
|
||||
|
||||
export default function ConsentPage() {
|
||||
const [params] = useSearchParams();
|
||||
const consentData = createAsync(() =>
|
||||
getConsentData(params.consent_challenge),
|
||||
);
|
||||
|
||||
async function consent(consent: boolean, consentChallenge: string) {
|
||||
const redirectUrl = await consentApi.consent(consent, consentChallenge);
|
||||
window.location.href = redirectUrl!;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<Loader text={t("consent.loading")} fillScreen={true} />}
|
||||
>
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="card bg-base-200 border-2 border-base-300 w-full max-w-md shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<img src="/logo.webp"></img>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<h1 class="text-2xl font-bold">{t("consent.title")}</h1>
|
||||
|
||||
<h2 class="text-lg">
|
||||
{t("consent.clientWouldLikeTo", {
|
||||
client: consentData()?.clientName ?? "",
|
||||
})}
|
||||
</h2>
|
||||
<ul class="list-disc">
|
||||
<For each={consentData()?.scopes}>
|
||||
{(scope) => (
|
||||
<li>
|
||||
{scopeMap.has(scope)
|
||||
? scopeMap.get(scope)!()
|
||||
: t("consent.scopes.default", { scope: scope })}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<div class="w-full py-2 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
consent(true, params.consent_challenge as string)
|
||||
}
|
||||
class="btn btn-success"
|
||||
>
|
||||
{t("consent.allow")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
consent(false, params.consent_challenge as string)
|
||||
}
|
||||
class="btn btn-error"
|
||||
>
|
||||
{t("consent.deny")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<section class="flex flex-col items-center justify-center min-h-[50vh] gap-8 p-8">
|
||||
<img class="w-lg" src="/logo.webp" alt="TrueOG logo"></img>
|
||||
<h1 class="text-4xl font-bold">{t("panel.welcome")}</h1>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { inviteApi } from "../lib/invite";
|
||||
|
||||
export const getInvites = query(
|
||||
async () => await inviteApi.getAll(),
|
||||
"invites",
|
||||
);
|
||||
|
||||
export function preloadInvites() {
|
||||
getInvites();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { FiPlus, FiX, FiCopy } from "solid-icons/fi";
|
||||
import { A, createAsync, revalidate } from "@solidjs/router";
|
||||
import { getInvites } from "./invites.data";
|
||||
import { createSignal, ErrorBoundary, For, Suspense } from "solid-js";
|
||||
import { t } from "../lib/i18n";
|
||||
import Error from "../components/Error";
|
||||
import Loader from "../components/Loader";
|
||||
import { inviteApi } from "../lib/invite";
|
||||
import { AppError } from "../types/api";
|
||||
import { getUsers } from "./user/users.data";
|
||||
import { userApi } from "../lib/user";
|
||||
import { InviteData } from "../types/invite";
|
||||
import { throwIfFatal } from "../lib/error";
|
||||
|
||||
function InviteRow({ invite }: { invite: InviteData }) {
|
||||
const user = createAsync(() => userApi.get(invite.invitedUserId));
|
||||
const link = `${window.location.origin}/setup?token=${invite.token}`;
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [deleting, setDeleting] = createSignal(false);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex flex-row items-center">
|
||||
<span>{link}</span>
|
||||
<div
|
||||
class="tooltip"
|
||||
data-tip={
|
||||
copied() ? t("panel.invites.copied") : t("panel.invites.copy")
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<FiCopy />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
class="rounded h-12 w-12 hidden md:block"
|
||||
src={`https://minotar.net/helm/${user()?.minecraftUuid.replaceAll("-", "")}.png`}
|
||||
alt={`${user()?.username}'s head`}
|
||||
/>
|
||||
<div class="font-bold">{user()?.username}</div>
|
||||
</div>
|
||||
</td>
|
||||
<th>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
await inviteApi.delete(invite.id.toString());
|
||||
revalidate("invites");
|
||||
}}
|
||||
class="btn btn-soft btn-error btn-xs"
|
||||
disabled={deleting()}
|
||||
>
|
||||
{t("panel.invites.delete")}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Invites() {
|
||||
const invites = createAsync(() => getInvites());
|
||||
|
||||
return (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("panel.invites.link")}</th>
|
||||
<th>{t("panel.invites.user")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={invites()}>
|
||||
{(invite) => <InviteRow invite={invite} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvitesPage() {
|
||||
const users = createAsync(() => getUsers());
|
||||
const [loading, setUpdating] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [userId, setUserId] = createSignal<string>();
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
async function create() {
|
||||
setError(null);
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
await inviteApi.create({
|
||||
invitedUserId: userId()!,
|
||||
});
|
||||
modalRef!.close();
|
||||
revalidate("invites");
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<section class="p-8">
|
||||
<div class="flex justify-between">
|
||||
<h1 class="text-2xl font-bold mb-4">{t("panel.invites.title")}</h1>
|
||||
<button onClick={() => modalRef!.showModal()} class="btn btn-soft">
|
||||
<FiPlus class="text-lg" />
|
||||
{t("panel.invites.createNew")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={(_, reset) => (
|
||||
<Error
|
||||
text={t("panel.invites.error.failedToLoad")}
|
||||
fillScreen={true}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader text={t("panel.invites.loading")} fillScreen={true} />
|
||||
}
|
||||
>
|
||||
<Invites />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
||||
<dialog
|
||||
onTransitionEnd={(e) => {
|
||||
if (e.propertyName === "opacity" && !modalRef!.open) {
|
||||
setUserId(undefined);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
ref={modalRef!}
|
||||
id="create_invite_modal"
|
||||
class="modal"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
<FiX class="text-lg" />
|
||||
</button>
|
||||
</form>
|
||||
<h3 class="text-lg font-bold mb-2">{t("panel.invites.createNew")}</h3>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("panel.invites.user")}</label>
|
||||
<select
|
||||
value={userId() ?? ""}
|
||||
onChange={(e) => setUserId(e.target.value as string)}
|
||||
class="select w-full"
|
||||
required
|
||||
>
|
||||
<option value="" disabled selected>
|
||||
{t("panel.invites.pickUser")}
|
||||
</option>
|
||||
<For each={users()}>
|
||||
{(user) => <option value={user.id}>{user.username}</option>}
|
||||
</For>
|
||||
</select>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary flex-1 mt-2"
|
||||
disabled={loading()}
|
||||
>
|
||||
{t("panel.invites.create")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>Close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FiCheck } from "solid-icons/fi";
|
||||
import { LOGIN_URL } from "../lib/api";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export default function LoggedOutPage() {
|
||||
return (
|
||||
<div class="flex flex-col min-h-screen items-center justify-center">
|
||||
<FiCheck class="h-16 w-full mb-8" />
|
||||
<p class="text-xl text-center">{t("logout.loggedOut")}</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = LOGIN_URL)}
|
||||
class="btn btn-soft mt-4"
|
||||
>
|
||||
{t("logout.logBackIn")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { AppError } from "../types/api";
|
||||
import { loginApi } from "../lib/login";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const getLoginStage = query(
|
||||
async (loginChallenge: string | string[] | undefined) => {
|
||||
if (!loginChallenge)
|
||||
throw new AppError("fatal", t("login.error.noChallenge"), null);
|
||||
if (typeof loginChallenge !== "string")
|
||||
throw new AppError("fatal", t("login.error.invalidChallenge"), null);
|
||||
return await loginApi.currentStage(loginChallenge);
|
||||
},
|
||||
"loginStage",
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import Credentials from "../components/login/Credentials";
|
||||
import { Match, Suspense, Switch } from "solid-js";
|
||||
import { createAsync, revalidate, useSearchParams } from "@solidjs/router";
|
||||
import MinecraftCheck from "../components/login/MinecraftCheck";
|
||||
import Totp from "../components/login/Totp";
|
||||
import { t } from "../lib/i18n";
|
||||
import { getLoginStage } from "./login.data";
|
||||
import Loader from "../components/Loader";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [params] = useSearchParams();
|
||||
const stage = createAsync(() => getLoginStage(params.login_challenge));
|
||||
|
||||
return (
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="card bg-base-200 border-2 border-base-300 w-full max-w-md shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<img src="/logo.webp" alt="TrueOG logo"></img>
|
||||
<Suspense
|
||||
fallback={<Loader text={t("login.loading")} fillScreen={false} />}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={stage()?.type === "credentials"}>
|
||||
<Credentials
|
||||
loginChallenge={params.login_challenge as string}
|
||||
done={() => revalidate("loginStage")}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={stage()?.type === "minecraftCheck"}>
|
||||
<MinecraftCheck
|
||||
loginChallenge={params.login_challenge as string}
|
||||
done={() => revalidate("loginStage")}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={stage()?.type === "totp"}>
|
||||
<Totp loginChallenge={params.login_challenge as string} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { AppError } from "../types/api";
|
||||
import { setupApi } from "../lib/setup";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export const getSetupStage = query(
|
||||
async (token: string | string[] | undefined) => {
|
||||
if (!token) throw new AppError("fatal", t("setup.error.noToken"), null);
|
||||
if (typeof token !== "string")
|
||||
throw new AppError("fatal", t("setup.error.invalidToken"), null);
|
||||
return await setupApi.currentStage(token);
|
||||
},
|
||||
"setupStage",
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Match, Suspense, Switch } from "solid-js";
|
||||
import { createAsync, revalidate, useSearchParams } from "@solidjs/router";
|
||||
import { t } from "../lib/i18n";
|
||||
import Loader from "../components/Loader";
|
||||
import { getSetupStage } from "./setup.data";
|
||||
import Details from "../components/setup/Details";
|
||||
import MinecraftCheck from "../components/setup/MinecraftCheck";
|
||||
import TotpSetup from "../components/setup/TotpSetup";
|
||||
|
||||
export default function SetupPage() {
|
||||
const [params] = useSearchParams();
|
||||
const stage = createAsync(() => getSetupStage(params.token));
|
||||
|
||||
return (
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="card bg-base-200 border-2 border-base-300 w-full max-w-md shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<img src="/logo.webp" alt="TrueOG logo"></img>
|
||||
<Suspense
|
||||
fallback={<Loader text={t("setup.loading")} fillScreen={false} />}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={stage()?.type === "details"}>
|
||||
<Details
|
||||
token={params.token as string}
|
||||
done={() => revalidate("setupStage")}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={stage()?.type === "minecraftCheck"}>
|
||||
<MinecraftCheck
|
||||
token={params.token as string}
|
||||
done={() => revalidate("setupStage")}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={stage()?.type === "totp"}>
|
||||
<TotpSetup
|
||||
token={params.token as string}
|
||||
done={() => revalidate("setupStage")}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FiCheck } from "solid-icons/fi";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export default function SuccessfulSetupPage() {
|
||||
return (
|
||||
<div class="flex flex-col min-h-screen items-center justify-center">
|
||||
<FiCheck class="h-16 w-full mb-8" />
|
||||
<p class="text-xl text-center">{t("setup.success")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { userApi } from "../../lib/user";
|
||||
|
||||
export const getUser = query(
|
||||
async (id: string) => await userApi.get(id),
|
||||
"user",
|
||||
);
|
||||
|
||||
export function preloadUser({ params }: { params: { id: string } }) {
|
||||
getUser(params.id);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { FiChevronLeft } from "solid-icons/fi";
|
||||
import {
|
||||
createAsync,
|
||||
revalidate,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "@solidjs/router";
|
||||
import { createEffect, createSignal, ErrorBoundary, Suspense } from "solid-js";
|
||||
import { getUser } from "./user.data";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { userApi } from "../../lib/user";
|
||||
import { Role } from "../../types/user";
|
||||
import Loader from "../../components/Loader";
|
||||
import Error from "../../components/Error";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
import { useUser } from "../../store/auth";
|
||||
|
||||
function User() {
|
||||
const authUser = useUser();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const user = createAsync(() => getUser(params.id));
|
||||
const [loading, setUpdating] = createSignal(false);
|
||||
const [success, setSuccess] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [email, setEmail] = createSignal<string>();
|
||||
const [minecraftUuid, setMinecraftUuid] = createSignal<string>();
|
||||
const [role, setRole] = createSignal<Role>();
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
setEmail(user()?.email);
|
||||
setMinecraftUuid(user()?.minecraftUuid);
|
||||
setRole(user()?.role);
|
||||
});
|
||||
|
||||
async function update() {
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const u = user();
|
||||
if (u) {
|
||||
await userApi.update({
|
||||
id: u.id,
|
||||
email: email(),
|
||||
role: role(),
|
||||
minecraftUuid: minecraftUuid(),
|
||||
});
|
||||
setSuccess(t("panel.users.updated"));
|
||||
revalidate("user");
|
||||
revalidate("users");
|
||||
} else {
|
||||
setError(t("panel.error.couldNotUpdate"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(activate: boolean) {
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const u = user();
|
||||
if (u) {
|
||||
await userApi.update({
|
||||
id: u.id,
|
||||
deactivated: !activate,
|
||||
});
|
||||
setSuccess(
|
||||
activate
|
||||
? t("panel.users.reactivated")
|
||||
: t("panel.users.deactivated"),
|
||||
);
|
||||
revalidate("user");
|
||||
revalidate("users");
|
||||
} else {
|
||||
setError(t("panel.error.couldNotUpdate"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const u = user();
|
||||
if (u) {
|
||||
const success = await userApi.delete(u.id.toString());
|
||||
if (!success) {
|
||||
setError(t("panel.error.couldNotDelete"));
|
||||
return;
|
||||
}
|
||||
revalidate("user");
|
||||
revalidate("users");
|
||||
navigate("/users");
|
||||
}
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<div class="flex flex-col w-full xl:w-1/2">
|
||||
<div class="flex flex-row items-center mb-4">
|
||||
<button onClick={() => navigate(-1)} class="btn btn-ghost mr-2">
|
||||
<FiChevronLeft class="text-2xl" />
|
||||
</button>
|
||||
<img
|
||||
class="rounded h-12 w-12 mr-4"
|
||||
src={`https://minotar.net/helm/${user()?.minecraftUuid.replaceAll("-", "")}.png`}
|
||||
alt={`${user()?.username}'s head`}
|
||||
/>
|
||||
<h1 class="text-2xl font-bold">{user()?.username}</h1>
|
||||
</div>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
update();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("panel.users.email")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder={t("panel.users.email")}
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label class="label">{t("panel.users.minecraftUuid")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder={t("panel.users.minecraftUuid")}
|
||||
value={minecraftUuid()}
|
||||
onInput={(e) => setMinecraftUuid(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label class="label">{t("panel.users.role")}</label>
|
||||
<select
|
||||
value={role()}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
class="select w-full"
|
||||
required
|
||||
>
|
||||
<option value="HELPER">{t("panel.users.roles.helper")}</option>
|
||||
<option value="MODERATOR">
|
||||
{t("panel.users.roles.moderator")}
|
||||
</option>
|
||||
<option value="ADMIN">{t("panel.users.roles.admin")}</option>
|
||||
</select>
|
||||
<label class="label">{t("panel.users.setUp")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder={t("panel.users.setUp")}
|
||||
value={user()?.setUp ? "Yes" : "No"}
|
||||
disabled
|
||||
/>
|
||||
{success() && <p class="text-success mt-2">{success()}</p>}
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<div class="flex flex-col sm:flex-row mt-4 gap-2 h-22">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary flex-1"
|
||||
disabled={loading()}
|
||||
>
|
||||
{t("panel.users.update")}
|
||||
</button>
|
||||
<div
|
||||
class="tooltip flex-1"
|
||||
data-tip={
|
||||
user()?.id.toString() === authUser.user()?.sub
|
||||
? t("panel.users.cannotDeactivateSelf")
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const deactivated = user()?.deactivated;
|
||||
if (deactivated == null) return;
|
||||
activate(deactivated);
|
||||
}}
|
||||
class="btn btn-warning w-full"
|
||||
disabled={
|
||||
loading() || user()?.id.toString() === authUser.user()?.sub
|
||||
}
|
||||
>
|
||||
{user()?.deactivated
|
||||
? t("panel.users.reactivate")
|
||||
: t("panel.users.deactivate")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={deleteUser}
|
||||
class="btn btn-error flex-1"
|
||||
disabled={loading()}
|
||||
>
|
||||
{t("panel.users.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserPage() {
|
||||
return (
|
||||
<section class="p-8">
|
||||
<ErrorBoundary
|
||||
fallback={(_, reset) => (
|
||||
<Error
|
||||
text={t("panel.users.error.failedToLoadUser")}
|
||||
fillScreen={true}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader text={t("panel.users.loadingUser")} fillScreen={true} />
|
||||
}
|
||||
>
|
||||
<User />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { query } from "@solidjs/router";
|
||||
import { userApi } from "../../lib/user";
|
||||
|
||||
export const getUsers = query(async () => await userApi.getAll(), "users");
|
||||
|
||||
export function preloadUsers() {
|
||||
getUsers();
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { FiPlus, FiX } from "solid-icons/fi";
|
||||
import { A, createAsync, revalidate } from "@solidjs/router";
|
||||
import { getUsers } from "./users.data";
|
||||
import { createSignal, ErrorBoundary, For, Suspense } from "solid-js";
|
||||
import { t } from "../../lib/i18n";
|
||||
import Error from "../../components/Error";
|
||||
import Loader from "../../components/Loader";
|
||||
import { Role } from "../../types/user";
|
||||
import { userApi } from "../../lib/user";
|
||||
import { AppError } from "../../types/api";
|
||||
import { throwIfFatal } from "../../lib/error";
|
||||
|
||||
function Users() {
|
||||
const users = createAsync(() => getUsers());
|
||||
|
||||
return (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("panel.users.username")}</th>
|
||||
<th>{t("panel.users.email")}</th>
|
||||
<th>{t("panel.users.minecraftUuid")}</th>
|
||||
<th>{t("panel.users.role")}</th>
|
||||
<th>{t("panel.users.deactivated")}</th>
|
||||
<th>{t("panel.users.setUp")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
class="rounded h-12 w-12 hidden md:block"
|
||||
src={`https://minotar.net/helm/${user.minecraftUuid.replaceAll("-", "")}.png`}
|
||||
alt={`${user.username}'s head`}
|
||||
/>
|
||||
<div class="font-bold">{user.username}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.minecraftUuid}</td>
|
||||
<td>
|
||||
{user.role[0].toUpperCase() +
|
||||
user.role.slice(1).toLowerCase()}
|
||||
</td>
|
||||
<td>{user.deactivated ? t("panel.yes") : t("panel.no")}</td>
|
||||
<td>{user.setUp ? t("panel.yes") : t("panel.no")}</td>
|
||||
<th>
|
||||
<A href={`/user/${user.id}`} class="btn btn-soft btn-xs">
|
||||
{t("panel.users.change")}
|
||||
</A>
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [loading, setUpdating] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [email, setEmail] = createSignal<string>("");
|
||||
const [minecraftUuid, setMinecraftUuid] = createSignal<string>("");
|
||||
const [role, setRole] = createSignal<Role>();
|
||||
const [fatalError, setFatalError] = createSignal<Error | null>(null);
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
async function create() {
|
||||
setError(null);
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
await userApi.create({
|
||||
email: email(),
|
||||
role: role()!,
|
||||
minecraftUuid: minecraftUuid()!,
|
||||
});
|
||||
modalRef!.close();
|
||||
revalidate("users");
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError)) {
|
||||
setFatalError(err as Error);
|
||||
return;
|
||||
}
|
||||
if (err.kind === "fatal") {
|
||||
setFatalError(err);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
return;
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{throwIfFatal(fatalError, () => setFatalError(null))()}
|
||||
|
||||
<section class="p-8">
|
||||
<div class="flex justify-between">
|
||||
<h1 class="text-2xl font-bold mb-4">{t("panel.users.title")}</h1>
|
||||
<button onClick={() => modalRef!.showModal()} class="btn btn-soft">
|
||||
<FiPlus class="text-lg" />
|
||||
{t("panel.users.createNew")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={(_, reset) => (
|
||||
<Error
|
||||
text={t("panel.users.error.failedToLoadUsers")}
|
||||
fillScreen={true}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader text={t("panel.users.loadingUsers")} fillScreen={true} />
|
||||
}
|
||||
>
|
||||
<Users />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
||||
<dialog
|
||||
onTransitionEnd={(e) => {
|
||||
if (e.propertyName === "opacity" && !modalRef!.open) {
|
||||
setEmail("");
|
||||
setMinecraftUuid("");
|
||||
setRole();
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
ref={modalRef!}
|
||||
id="create_user_modal"
|
||||
class="modal"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
<FiX class="text-lg" />
|
||||
</button>
|
||||
</form>
|
||||
<h3 class="text-lg font-bold mb-2">{t("panel.users.createNew")}</h3>
|
||||
<form
|
||||
class="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create();
|
||||
}}
|
||||
>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">{t("panel.users.email")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder={t("panel.users.email")}
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label class="label">{t("panel.users.minecraftUuid")}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input validator w-full"
|
||||
placeholder={t("panel.users.minecraftUuid")}
|
||||
value={minecraftUuid()}
|
||||
onInput={(e) => setMinecraftUuid(e.target.value)}
|
||||
required
|
||||
pattern="^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$"
|
||||
title={t("panel.users.error.enterValidUuid")}
|
||||
/>
|
||||
<div class="validator-hint hidden">
|
||||
{t("panel.users.error.enterValidUuid")}
|
||||
</div>
|
||||
<label class="label">{t("panel.users.role")}</label>
|
||||
<select
|
||||
value={role() ?? ""}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
class="select w-full"
|
||||
required
|
||||
>
|
||||
<option value="" disabled selected>
|
||||
{t("panel.users.roles.pick")}
|
||||
</option>
|
||||
<option value="HELPER">{t("panel.users.roles.helper")}</option>
|
||||
<option value="MODERATOR">
|
||||
{t("panel.users.roles.moderator")}
|
||||
</option>
|
||||
<option value="ADMIN">{t("panel.users.roles.admin")}</option>
|
||||
</select>
|
||||
{error() && <p class="text-error mt-2">{error()}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary flex-1 mt-2"
|
||||
disabled={loading()}
|
||||
>
|
||||
{t("panel.users.create")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>Close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { lazy } from "solid-js";
|
||||
import type { RouteDefinition } from "@solidjs/router";
|
||||
|
||||
import AppLayout from "./components/AppLayout";
|
||||
import { preloadUsers } from "./pages/user/users.data";
|
||||
import { preloadUser } from "./pages/user/user.data";
|
||||
import BasicLayout from "./components/BasicLayout";
|
||||
import { preloadInvites } from "./pages/invites.data";
|
||||
|
||||
const Login = lazy(() => import("./pages/login"));
|
||||
const Consent = lazy(() => import("./pages/consent"));
|
||||
|
||||
const Setup = lazy(() => import("./pages/setup"));
|
||||
|
||||
export const routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/",
|
||||
component: (props) => <AppLayout>{props.children}</AppLayout>,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: lazy(() => import("./pages/home")),
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
component: lazy(() => import("./pages/user/users")),
|
||||
preload: () => preloadUsers,
|
||||
},
|
||||
{
|
||||
path: "/user/:id",
|
||||
component: lazy(() => import("./pages/user/user")),
|
||||
preload: () => preloadUser,
|
||||
},
|
||||
{
|
||||
path: "/invites",
|
||||
component: lazy(() => import("./pages/invites")),
|
||||
preload: () => preloadInvites,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => (
|
||||
<BasicLayout>
|
||||
<Login />
|
||||
</BasicLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/consent",
|
||||
component: () => (
|
||||
<BasicLayout>
|
||||
<Consent />
|
||||
</BasicLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/setup",
|
||||
component: () => (
|
||||
<BasicLayout>
|
||||
<Setup />
|
||||
</BasicLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/successful-setup",
|
||||
component: lazy(() => import("./pages/successfulSetup")),
|
||||
},
|
||||
{
|
||||
path: "/logged-out",
|
||||
component: lazy(() => import("./pages/loggedOut")),
|
||||
},
|
||||
{
|
||||
path: "**",
|
||||
component: lazy(() => import("./errors/404")),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { AppError, MeData } from "../types/api";
|
||||
|
||||
const [user, setUser] = createSignal<MeData | null>(null);
|
||||
const [error, setError] = createSignal<Error | null>(null);
|
||||
let loaded = false;
|
||||
|
||||
export function useUser() {
|
||||
if (!loaded) {
|
||||
api
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch((e) => setError(e));
|
||||
loaded = true;
|
||||
}
|
||||
return { user, error };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type MeData = {
|
||||
sub: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export type ApiError = { message: string; status: number };
|
||||
|
||||
export class AppError extends Error {
|
||||
kind: "fatal" | "local";
|
||||
status: number | null;
|
||||
|
||||
constructor(kind: "fatal" | "local", message: string, status: number | null) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConsentData = {
|
||||
skip: boolean;
|
||||
clientName: string;
|
||||
scopes: string[];
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type InviteData = {
|
||||
id: number;
|
||||
token: string;
|
||||
invitedUserId: string;
|
||||
};
|
||||
|
||||
export type CreateInviteData = {
|
||||
invitedUserId: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type LoginStage =
|
||||
| { type: "loading" }
|
||||
| { type: "credentials" }
|
||||
| { type: "minecraftCheck" }
|
||||
| { type: "totp" };
|
||||
@@ -0,0 +1,11 @@
|
||||
export type SetupStage =
|
||||
| { type: "loading" }
|
||||
| { type: "details" }
|
||||
| { type: "minecraftCheck" }
|
||||
| { type: "totp" }
|
||||
| { type: "totpVerify" };
|
||||
|
||||
export type TotpData = {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export type UserData = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: Role;
|
||||
minecraftUuid: string;
|
||||
setUp: boolean;
|
||||
deactivated: boolean;
|
||||
};
|
||||
|
||||
export type UpdateUserData = {
|
||||
id: number;
|
||||
email?: string;
|
||||
role?: Role;
|
||||
minecraftUuid?: string;
|
||||
deactivated?: boolean;
|
||||
};
|
||||
|
||||
export type CreateUserData = {
|
||||
email?: string;
|
||||
role: Role;
|
||||
minecraftUuid: string;
|
||||
};
|
||||
|
||||
export type Role = "ADMIN" | "MODERATOR" | "HELPER";
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// General
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"target": "ESNext",
|
||||
|
||||
// Modules
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
|
||||
// Type Checking & Safety
|
||||
"strict": true,
|
||||
"types": ["vite/client"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import devtools from "solid-devtools/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), devtools(), solidPlugin()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user