Initial commit

This commit is contained in:
SKBotNL committed 2026-05-22 14:41:55 +02:00
commit 577f74e87c
58 files changed
+5041

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080
+28
View File
@@ -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
+24
View File
@@ -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/>
+12
View File
@@ -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
View File
@@ -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>
+38
View File
@@ -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"
]
}
}
+2126
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+13
View File
@@ -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;
+180
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+86
View File
@@ -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>
</>
);
}
+67
View File
@@ -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>
</>
);
}
+72
View File
@@ -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>
</>
);
}
+80
View File
@@ -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>
</>
);
}
+67
View File
@@ -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>
</>
);
}
+106
View File
@@ -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>
);
}
+10
View File
@@ -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
View File
@@ -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",
},
};
+20
View File
@@ -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);
}
}*/
+22
View File
@@ -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!,
);
+50
View File
@@ -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
);
}
+67
View File
@@ -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");
}
}
+11
View File
@@ -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;
}
};
}
+31
View File
@@ -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,
);
+99
View File
@@ -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");
}
}
+127
View File
@@ -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);
}
}
+135
View File
@@ -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
View File
@@ -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");
}
}
+15
View File
@@ -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",
);
+76
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+11
View File
@@ -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();
}
+221
View File
@@ -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>
</>
);
}
+18
View File
@@ -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>
);
}
+15
View File
@@ -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",
);
+44
View File
@@ -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>
);
}
+14
View File
@@ -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",
);
+47
View File
@@ -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>
);
}
+11
View File
@@ -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>
);
}
+11
View File
@@ -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);
}
+268
View File
@@ -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>
);
}
+8
View File
@@ -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();
}
+220
View File
@@ -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>
</>
);
}
+77
View File
@@ -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")),
},
];
+18
View File
@@ -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 };
}
+19
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export type ConsentData = {
skip: boolean;
clientName: string;
scopes: string[];
};
+9
View File
@@ -0,0 +1,9 @@
export type InviteData = {
id: number;
token: string;
invitedUserId: string;
};
export type CreateInviteData = {
invitedUserId: string;
};
+5
View File
@@ -0,0 +1,5 @@
export type LoginStage =
| { type: "loading" }
| { type: "credentials" }
| { type: "minecraftCheck" }
| { type: "totp" };
+11
View File
@@ -0,0 +1,11 @@
export type SetupStage =
| { type: "loading" }
| { type: "details" }
| { type: "minecraftCheck" }
| { type: "totp" }
| { type: "totpVerify" };
export type TotpData = {
secret: string;
qrCode: string;
};
+25
View File
@@ -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";
+20
View File
@@ -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"],
},
}
+14
View File
@@ -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",
},
});