- Implemented a theme toggle feature allowing users to switch between light and dark modes. - Added a new ThemeToggle component for the theme switching button. - Updated theme CSS to support dark mode variables and system preference detection. - Enhanced localization files to include translations for the "toggleTheme" key across multiple languages.
564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { jwt } from "@elysiajs/jwt";
|
||
import { Elysia, t } from "elysia";
|
||
import { BaseHtml } from "../components/base";
|
||
import { Header } from "../components/header";
|
||
import { LanguageSelector } from "../components/languageSelector";
|
||
import { ThemeToggle } from "../components/themeToggle";
|
||
import db from "../db/db";
|
||
import { User } from "../db/types";
|
||
import {
|
||
ACCOUNT_REGISTRATION,
|
||
ALLOW_UNAUTHENTICATED,
|
||
HIDE_HISTORY,
|
||
HTTP_ALLOWED,
|
||
TRUST_PROXY,
|
||
WEBROOT,
|
||
} from "../helpers/env";
|
||
import { localeService } from "../i18n/service";
|
||
|
||
export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
|
||
|
||
// ==============================================================================
|
||
// Cookie 設定輔助函數
|
||
// ==============================================================================
|
||
// 解決遠端部署時登入失敗的問題:
|
||
// 1. sameSite: "lax" - 允許導航時傳送 Cookie(strict 會阻擋)
|
||
// 2. path: WEBROOT || "/" - 確保 Cookie 覆蓋整個應用
|
||
// 3. secure: 考慮 TRUST_PROXY 設定
|
||
// ==============================================================================
|
||
function getCookieOptions() {
|
||
return {
|
||
httpOnly: true,
|
||
secure: !HTTP_ALLOWED && !TRUST_PROXY ? true : false,
|
||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||
sameSite: "lax" as const,
|
||
path: WEBROOT || "/",
|
||
};
|
||
}
|
||
|
||
export const userService = new Elysia({ name: "user/service" })
|
||
.use(
|
||
jwt({
|
||
name: "jwt",
|
||
schema: t.Object({
|
||
id: t.String(),
|
||
}),
|
||
secret: process.env.JWT_SECRET ?? randomUUID(),
|
||
exp: "7d",
|
||
}),
|
||
)
|
||
.model({
|
||
signIn: t.Object({
|
||
email: t.String(),
|
||
password: t.String(),
|
||
}),
|
||
session: t.Cookie({
|
||
auth: t.String(),
|
||
jobId: t.Optional(t.String()),
|
||
}),
|
||
optionalSession: t.Cookie({
|
||
auth: t.Optional(t.String()),
|
||
jobId: t.Optional(t.String()),
|
||
}),
|
||
})
|
||
.macro("auth", {
|
||
cookie: "session",
|
||
async resolve({ status, jwt, cookie: { auth } }) {
|
||
if (!auth.value) {
|
||
return status(401, {
|
||
success: false,
|
||
message: "Unauthorized",
|
||
});
|
||
}
|
||
const user = await jwt.verify(auth.value);
|
||
if (!user) {
|
||
return status(401, {
|
||
success: false,
|
||
message: "Unauthorized",
|
||
});
|
||
}
|
||
return {
|
||
success: true,
|
||
user,
|
||
};
|
||
},
|
||
});
|
||
|
||
export const user = new Elysia()
|
||
.use(userService)
|
||
.use(localeService)
|
||
.get("/setup", ({ redirect, locale, t }) => {
|
||
if (!FIRST_RUN) {
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
}
|
||
|
||
return (
|
||
<BaseHtml title="ConvertX-CN | Setup" webroot={WEBROOT} locale={locale}>
|
||
<>
|
||
<header class="w-full p-4">
|
||
<nav
|
||
class={`
|
||
mx-auto flex max-w-4xl items-center justify-between rounded-sm bg-neutral-900 p-4
|
||
`}
|
||
>
|
||
<strong>ConvertX-CN</strong>
|
||
<div class="flex items-center gap-4">
|
||
<ThemeToggle locale={locale} t={t} />
|
||
<LanguageSelector currentLocale={locale} webroot={WEBROOT} t={t} />
|
||
</div>
|
||
</nav>
|
||
</header>
|
||
<main
|
||
class={`
|
||
mx-auto w-full max-w-4xl flex-1 px-2
|
||
sm:px-4
|
||
`}
|
||
>
|
||
<h1 class="my-8 text-3xl">{t("setup", "welcome")}</h1>
|
||
<article class="article p-0">
|
||
<header class="w-full bg-neutral-800 p-4">{t("setup", "createYourAccount")}</header>
|
||
<form method="post" action={`${WEBROOT}/register`} class="p-4">
|
||
<fieldset class="mb-4 flex flex-col gap-4">
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "email")}
|
||
<input
|
||
type="email"
|
||
name="email"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "email")}
|
||
autocomplete="email"
|
||
required
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "password")}
|
||
<input
|
||
type="password"
|
||
name="password"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "password")}
|
||
autocomplete="current-password"
|
||
required
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
<input type="submit" value={t("auth", "createAccount")} class="btn-primary" />
|
||
</form>
|
||
<footer class="p-4">
|
||
{t("setup", "reportIssues")}{" "}
|
||
<a
|
||
class={`
|
||
text-accent-500 underline
|
||
hover:text-accent-400
|
||
`}
|
||
href="https://github.com/pi-docket/ConvertX-CN"
|
||
>
|
||
{t("setup", "github")}
|
||
</a>
|
||
.
|
||
</footer>
|
||
</article>
|
||
</main>
|
||
</>
|
||
</BaseHtml>
|
||
);
|
||
})
|
||
.get("/register", ({ locale, t }) => {
|
||
// 移除 ACCOUNT_REGISTRATION 限制,讓註冊頁面始終可用
|
||
return (
|
||
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Register" locale={locale}>
|
||
<>
|
||
<Header
|
||
webroot={WEBROOT}
|
||
accountRegistration={true}
|
||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||
hideHistory={HIDE_HISTORY}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
<main
|
||
class={`
|
||
w-full flex-1 px-2
|
||
sm:px-4
|
||
`}
|
||
>
|
||
<article class="article">
|
||
<form method="post" class="flex flex-col gap-4">
|
||
<fieldset class="mb-4 flex flex-col gap-4">
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "email")}
|
||
<input
|
||
type="email"
|
||
name="email"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "email")}
|
||
autocomplete="email"
|
||
required
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "password")}
|
||
<input
|
||
type="password"
|
||
name="password"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "password")}
|
||
autocomplete="current-password"
|
||
required
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
<input
|
||
type="submit"
|
||
value={t("auth", "registerButton")}
|
||
class="w-full btn-primary"
|
||
/>
|
||
</form>
|
||
</article>
|
||
</main>
|
||
</>
|
||
</BaseHtml>
|
||
);
|
||
})
|
||
.post(
|
||
"/register",
|
||
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
|
||
// 移除 ACCOUNT_REGISTRATION 限制,讓註冊功能始終可用
|
||
if (FIRST_RUN) {
|
||
FIRST_RUN = false;
|
||
}
|
||
|
||
const existingUser = await db.query("SELECT * FROM users WHERE email = ?").get(email);
|
||
if (existingUser) {
|
||
set.status = 400;
|
||
return {
|
||
message: "Email already in use.",
|
||
};
|
||
}
|
||
const savedPassword = await Bun.password.hash(password);
|
||
|
||
db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, savedPassword);
|
||
|
||
const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
|
||
|
||
if (!user) {
|
||
set.status = 500;
|
||
return {
|
||
message: "Failed to create user.",
|
||
};
|
||
}
|
||
|
||
const accessToken = await jwt.sign({
|
||
id: String(user.id),
|
||
});
|
||
|
||
if (!auth) {
|
||
set.status = 500;
|
||
return {
|
||
message: "No auth cookie, perhaps your browser is blocking cookies.",
|
||
};
|
||
}
|
||
|
||
// set cookie with proper options for remote deployment
|
||
auth.set({
|
||
value: accessToken,
|
||
...getCookieOptions(),
|
||
});
|
||
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
},
|
||
{ body: "signIn" },
|
||
)
|
||
.get(
|
||
"/login",
|
||
async ({ jwt, redirect, cookie: { auth }, locale, t }) => {
|
||
if (FIRST_RUN) {
|
||
return redirect(`${WEBROOT}/setup`, 302);
|
||
}
|
||
|
||
// if already logged in, redirect to home
|
||
if (auth?.value) {
|
||
const user = await jwt.verify(auth.value);
|
||
|
||
if (user) {
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
}
|
||
|
||
auth.remove();
|
||
}
|
||
|
||
return (
|
||
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Login" locale={locale}>
|
||
<>
|
||
<Header
|
||
webroot={WEBROOT}
|
||
accountRegistration={ACCOUNT_REGISTRATION}
|
||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||
hideHistory={HIDE_HISTORY}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
<main
|
||
class={`
|
||
w-full flex-1 px-2
|
||
sm:px-4
|
||
`}
|
||
>
|
||
<article class="article">
|
||
<form method="post" class="flex flex-col gap-4">
|
||
<fieldset class="mb-4 flex flex-col gap-4">
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "email")}
|
||
<input
|
||
type="email"
|
||
name="email"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "email")}
|
||
autocomplete="email"
|
||
required
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "password")}
|
||
<input
|
||
type="password"
|
||
name="password"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "password")}
|
||
autocomplete="current-password"
|
||
required
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
<div class="flex flex-row gap-4">
|
||
<a
|
||
href={`${WEBROOT}/register`}
|
||
role="button"
|
||
class="w-full btn-secondary text-center"
|
||
>
|
||
{t("nav", "register")}
|
||
</a>
|
||
<input
|
||
type="submit"
|
||
value={t("auth", "loginButton")}
|
||
class="w-full btn-primary"
|
||
/>
|
||
</div>
|
||
</form>
|
||
</article>
|
||
</main>
|
||
</>
|
||
</BaseHtml>
|
||
);
|
||
},
|
||
{ body: "signIn", cookie: "optionalSession" },
|
||
)
|
||
.post(
|
||
"/login",
|
||
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
|
||
const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email);
|
||
|
||
if (!existingUser) {
|
||
set.status = 403;
|
||
return {
|
||
message: "Invalid credentials.",
|
||
};
|
||
}
|
||
|
||
const validPassword = await Bun.password.verify(body.password, existingUser.password);
|
||
|
||
if (!validPassword) {
|
||
set.status = 403;
|
||
return {
|
||
message: "Invalid credentials.",
|
||
};
|
||
}
|
||
|
||
const accessToken = await jwt.sign({
|
||
id: String(existingUser.id),
|
||
});
|
||
|
||
if (!auth) {
|
||
set.status = 500;
|
||
return {
|
||
message: "No auth cookie, perhaps your browser is blocking cookies.",
|
||
};
|
||
}
|
||
|
||
// set cookie with proper options for remote deployment
|
||
auth.set({
|
||
value: accessToken,
|
||
...getCookieOptions(),
|
||
});
|
||
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
},
|
||
{ body: "signIn" },
|
||
)
|
||
.get("/logoff", ({ redirect, cookie: { auth } }) => {
|
||
if (auth?.value) {
|
||
auth.remove();
|
||
}
|
||
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
})
|
||
.post("/logoff", ({ redirect, cookie: { auth } }) => {
|
||
if (auth?.value) {
|
||
auth.remove();
|
||
}
|
||
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
})
|
||
.get(
|
||
"/account",
|
||
async ({ user, redirect, locale, t }) => {
|
||
if (!user) {
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
}
|
||
|
||
const userData = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
|
||
|
||
if (!userData) {
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
}
|
||
|
||
return (
|
||
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Account" locale={locale}>
|
||
<>
|
||
<Header
|
||
webroot={WEBROOT}
|
||
accountRegistration={ACCOUNT_REGISTRATION}
|
||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||
hideHistory={HIDE_HISTORY}
|
||
loggedIn
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
<main
|
||
class={`
|
||
w-full flex-1 px-2
|
||
sm:px-4
|
||
`}
|
||
>
|
||
<article class="article">
|
||
<form method="post" class="flex flex-col gap-4">
|
||
<fieldset class="mb-4 flex flex-col gap-4">
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "email")}
|
||
<input
|
||
type="email"
|
||
name="email"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "email")}
|
||
autocomplete="email"
|
||
value={userData.email}
|
||
required
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "newPassword")}
|
||
<input
|
||
type="password"
|
||
name="newPassword"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "password")}
|
||
autocomplete="new-password"
|
||
/>
|
||
</label>
|
||
<label class="flex flex-col gap-1">
|
||
{t("auth", "currentPassword")}
|
||
<input
|
||
type="password"
|
||
name="password"
|
||
class="rounded-sm bg-neutral-800 p-3"
|
||
placeholder={t("auth", "password")}
|
||
autocomplete="current-password"
|
||
required
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
<div role="group">
|
||
<input
|
||
type="submit"
|
||
value={t("auth", "updateButton")}
|
||
class={`w-full btn-primary`}
|
||
/>
|
||
</div>
|
||
</form>
|
||
</article>
|
||
</main>
|
||
</>
|
||
</BaseHtml>
|
||
);
|
||
},
|
||
{
|
||
auth: true,
|
||
},
|
||
)
|
||
.post(
|
||
"/account",
|
||
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
|
||
if (!auth?.value) {
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
}
|
||
|
||
const user = await jwt.verify(auth.value);
|
||
if (!user) {
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
}
|
||
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
|
||
|
||
if (!existingUser) {
|
||
if (auth?.value) {
|
||
auth.remove();
|
||
}
|
||
return redirect(`${WEBROOT}/login`, 302);
|
||
}
|
||
|
||
const validPassword = await Bun.password.verify(body.password, existingUser.password);
|
||
|
||
if (!validPassword) {
|
||
set.status = 403;
|
||
return {
|
||
message: "Invalid credentials.",
|
||
};
|
||
}
|
||
|
||
const fields = [];
|
||
const values = [];
|
||
|
||
if (body.email) {
|
||
const existingUser = await db
|
||
.query("SELECT id FROM users WHERE email = ?")
|
||
.as(User)
|
||
.get(body.email);
|
||
if (existingUser && existingUser.id.toString() !== user.id) {
|
||
set.status = 409;
|
||
return { message: "Email already in use." };
|
||
}
|
||
fields.push("email");
|
||
values.push(body.email);
|
||
}
|
||
if (body.newPassword) {
|
||
fields.push("password");
|
||
values.push(await Bun.password.hash(body.newPassword));
|
||
}
|
||
|
||
if (fields.length > 0) {
|
||
db.query(
|
||
`UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`,
|
||
).run(...values, user.id);
|
||
}
|
||
|
||
return redirect(`${WEBROOT}/`, 302);
|
||
},
|
||
{
|
||
body: t.Object({
|
||
email: t.MaybeEmpty(t.String()),
|
||
newPassword: t.MaybeEmpty(t.String()),
|
||
password: t.String(),
|
||
}),
|
||
cookie: "session",
|
||
},
|
||
);
|