convertor/src/pages/user.tsx
Your Name db5f47586e feat: add theme toggle functionality and update localization files
- 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.
2026-01-21 15:00:34 +08:00

564 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" - 允許導航時傳送 Cookiestrict 會阻擋)
// 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",
},
);