diff --git a/app/components/icons.tsx b/app/components/icons.tsx index 7625d1e..c2d6e21 100644 --- a/app/components/icons.tsx +++ b/app/components/icons.tsx @@ -36,6 +36,7 @@ function icon(node: IconNode) { export type LucideIcon = ReturnType; +export const AppWindow = icon(lucide.AppWindow); export const ArrowDown = icon(lucide.ArrowDown); export const ArrowLeft = icon(lucide.ArrowLeft); export const ArrowRight = icon(lucide.ArrowRight); diff --git a/app/routes.ts b/app/routes.ts index 82801d9..fca3271 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -21,6 +21,7 @@ export default [ route("login", "routes/login.tsx"), route("login/link", "routes/login-link.tsx"), route("unsubscribe", "routes/unsubscribe.tsx"), + route("oauth/authorize", "routes/oauth-authorize.tsx"), ]), // For fetchers on the forum's pages. route("new", "routes/new-thread.ts"), @@ -55,5 +56,6 @@ export default [ route("site", "routes/admin/site.tsx"), route("email", "routes/admin/email.tsx"), route("tokens", "routes/admin/tokens.tsx"), + route("apps", "routes/admin/apps.tsx"), ]), ] satisfies RouteConfig; diff --git a/app/routes/admin/apps.tsx b/app/routes/admin/apps.tsx new file mode 100644 index 0000000..56f8add --- /dev/null +++ b/app/routes/admin/apps.tsx @@ -0,0 +1,254 @@ +import { Copy } from "~/components/icons"; +import { pageTitle } from "~/share-meta"; +import { useEffect, useRef } from "react"; +import { Form, Link, useNavigation } from "react-router"; +import { apiContext } from "../../api.server.ts"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; +import { confirmSubmit } from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { Badge, Field, readForm, Result, Section, Time } from "~/components/form-parts"; +import type { Route } from "./+types/apps.ts"; + +export function meta() { + return [{ title: pageTitle("应用", "管理后台") }]; +} + +const SCOPES = [ + { scope: "profile", hint: "读取本人的资料,登录用" }, + { scope: "read", hint: "读取本人能看到的全部内容" }, + { scope: "act", hint: "以本人的身份发帖、回复和互动" }, +] as const; + +export async function loader({ request, context }: Route.LoaderArgs) { + const res = await context.get(apiContext).admin.apps.$get(); + if (!res.ok) throw new Error("Admin apps failed"); + const edit = Number(new URL(request.url).searchParams.get("edit")); + return { apps: (await res.json()).items, edit }; +} + +export async function action({ request, context }: Route.ActionArgs) { + const field = await readForm(request); + const api = context.get(apiContext).admin.apps; + const intent = field("intent"); + const param = { id: field("id") }; + const fields = () => ({ + name: field("name"), + redirect_uris: field("redirect_uris").split(/\s+/).filter(Boolean), + scopes: SCOPES.map((s) => s.scope).filter((s) => field(s) === "on"), + }); + if (intent === "create") { + const res = await api.$post({ json: fields() }); + if (!res.ok) return { ok: false, message: (await res.json()).error.message, secret: null }; + const { client_id, client_secret } = await res.json(); + return { ok: true, message: "已登记", secret: { client_id, client_secret } }; + } + if (intent === "secret") { + const res = await api[":id"].secret.$post({ param }); + if (!res.ok) return { ok: false, message: (await res.json()).error.message, secret: null }; + const { client_secret } = await res.json(); + return { + ok: true, + message: "已换密钥", + secret: { client_id: field("client_id"), client_secret }, + }; + } + const res = await api[":id"].$patch({ + param, + json: intent === "update" ? fields() : { disabled: intent === "disable" }, + }); + if (res.ok) return { ok: true, message: "已保存", secret: null }; + return { ok: false, message: (await res.json()).error.message, secret: null }; +} + +type App = Route.ComponentProps["loaderData"]["apps"][number]; + +export default function Apps({ loaderData: { apps, edit }, actionData }: Route.ComponentProps) { + const busy = useNavigation().state !== "idle"; + const createForm = useRef(null); + useEffect(() => { + if (actionData?.secret) createForm.current?.reset(); + }, [actionData]); + return ( + <> +

应用

+

+ 鼠钟等自家网站用论坛的账号登录,不显示授权页。登记后把 client_id 和密钥填进应用的配置 +

+ {actionData?.secret ? : } + +
+ {apps.length === 0 &&

暂无应用

} +
    + {apps.map((app) => + app.id === edit ? ( +
  • + {/* Posts to the bare page, so saving also leaves edit mode. */} +
    + + +
    + + + 取消 + +
    + +
  • + ) : ( +
  • +
    +
    + {app.name} + {app.disabled && 已停用} +
    +
    + {app.client_id} +
    +
    + {app.redirect_uris.join("、")} · {app.scopes.join("、")} +
    +
    + {app.tokens} 人的令牌有效 · 最后使用{" "} +
    +
    +
    + + + + 修改 + + + {app.disabled ? ( + + ) : ( + + )} +
    +
  • + ), + )} +
+
+ +
+
+ + +
+
+ + ); +} + +function AppFields({ app }: { app?: App }) { + return ( + <> +
+ + + + +