v2.9.0: 设置新增 ZITADEL 统一登录(OIDC Authorization Code+PKCE,身份主键 (issuer,subject),JIT 建号即管理员,docs/12);账号密码 tab 支持修改用户名(验密+重签会话);新增 docs/API.md 全量 API 文档;迁移 0006(oidc_identities/oidc_states)
This commit is contained in:
@@ -161,7 +161,7 @@ export function App() {
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} onUsernameChanged={setUsername} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ export const retainEmails = (ids: string[]) =>
|
||||
export interface OperationLogRow {
|
||||
id: string;
|
||||
action: string;
|
||||
target_type: "account" | "domain" | "mailbox" | "email" | "webhook";
|
||||
target_type: "account" | "domain" | "mailbox" | "email" | "webhook" | "user" | "settings";
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
detail: string | null;
|
||||
@@ -338,6 +338,22 @@ export interface SettingsInfo {
|
||||
/** 实际请求的 scope(点号分隔,空格分隔多个) */
|
||||
scope: string;
|
||||
};
|
||||
/** ZITADEL 统一登录配置与当前账号绑定情况 */
|
||||
oidc: {
|
||||
issuer: string;
|
||||
client_id: string;
|
||||
enabled: boolean;
|
||||
/** JIT:未绑定的统一登录身份自动创建本地用户(即管理员) */
|
||||
jit: boolean;
|
||||
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
||||
source: "settings" | "env" | "none";
|
||||
/** 授权 scope(固定 openid profile) */
|
||||
scopes: string;
|
||||
/** 需要在 ZITADEL 控制台应用里登记的回调地址 */
|
||||
redirect_uri: string;
|
||||
/** 当前账号已绑定的统一登录身份(未绑定为 null) */
|
||||
identity: { issuer: string; subject: string; display_name: string | null; email: string | null } | null;
|
||||
};
|
||||
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
||||
redirect_uri: string;
|
||||
}
|
||||
@@ -345,10 +361,12 @@ export interface SettingsInfo {
|
||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
|
||||
/** 保存设置
|
||||
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */
|
||||
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值
|
||||
* oidc.enabled / oidc.jit 缺省表示沿用已保存的开关状态 */
|
||||
export const putSettings = (body: {
|
||||
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
||||
cf_oauth?: { client_id: string; secret?: string; scope?: string };
|
||||
oidc?: { issuer: string; client_id: string; enabled?: boolean; jit?: boolean };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
/** # 成员变量: 授权配置自检结果 **/
|
||||
@@ -376,6 +394,16 @@ export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE");
|
||||
export const changePassword = (old_password: string, new_password: string) =>
|
||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||
|
||||
/** 修改登录用户名(需当前密码确认;成功后服务端重签会话 cookie) */
|
||||
export const changeUsername = (new_username: string, password: string) =>
|
||||
api<{ ok: boolean; username: string }>("/api/settings/username", "PUT", { new_username, password });
|
||||
|
||||
/** 清除后台保存的 ZITADEL 统一登录配置(env 兜底仍可用) */
|
||||
export const deleteOidc = () => api("/api/settings/oidc", "DELETE");
|
||||
|
||||
/** 统一登录探测(公开接口,登录页用):enabled 时显示「ZITADEL 统一登录」按钮 */
|
||||
export const oidcStatus = () => api<{ enabled: boolean; issuer: string | null }>("/auth/status");
|
||||
|
||||
/* ---- API/MCP Key(单一 Key,设置模态窗口内使用;API 与 MCP 共用) ---- */
|
||||
|
||||
/** 生成(轮换)Key:明文仅本次返回 */
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 设置模态窗口(v2.3.0):
|
||||
* - 三个 tab:全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、账号密码
|
||||
* 设置模态窗口(v2.3.0;v2.9.0 四 tab):
|
||||
* - 全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、ZITADEL 统一登录、账号密码(用户名+密码)
|
||||
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
||||
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
|
||||
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
|
||||
* - ZITADEL 统一登录:issuer + Client ID(PKCE 公共客户端无 secret),回调地址 {origin}/auth/callback;
|
||||
* 登录成功即管理员(本平台没有权限设置),JIT 自动建号可开关
|
||||
* - 内容高度不超过屏幕(超出滚动)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -28,12 +31,15 @@ import {
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
SafetyOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
deleteCfOAuth,
|
||||
deleteOidc,
|
||||
getSettings,
|
||||
oauthPreflight,
|
||||
putSettings,
|
||||
@@ -45,7 +51,16 @@ import {
|
||||
/** # 函数
|
||||
* 设置模态窗口组件
|
||||
*/
|
||||
export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
onUsernameChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 用户名修改成功后回调(顶栏展示名同步) */
|
||||
onUsernameChanged?: (username: string) => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [info, setInfo] = useState<SettingsInfo | null>(null);
|
||||
|
||||
@@ -77,7 +92,8 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
items={[
|
||||
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
||||
{ key: "cloudflare", label: <span><CloudServerOutlined style={{ marginRight: 6 }} />Cloudflare 接入</span>, children: info ? <CloudflareTab info={info} onSaved={load} /> : null },
|
||||
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
||||
{ key: "oidc", label: <span><SafetyOutlined style={{ marginRight: 6 }} />ZITADEL 统一登录</span>, children: info ? <ZitadelTab info={info} onSaved={load} /> : null },
|
||||
{ key: "password", label: "账号密码", children: <AccountTab onUsernameChanged={onUsernameChanged} /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -494,16 +510,40 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: 账号密码 tab 的表单集合(用户名表单 + 密码表单) **/
|
||||
interface UsernameFormValues {
|
||||
new_username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号密码 tab:修改登录密码
|
||||
* 账号密码 tab:修改登录用户名(v2.9.0)+ 修改登录密码;成功后同步顶栏展示名
|
||||
*/
|
||||
function PasswordTab() {
|
||||
function AccountTab({ onUsernameChanged }: { onUsernameChanged?: (username: string) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [nameForm] = Form.useForm<UsernameFormValues>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
/** 修改用户名(需当前密码确认) */
|
||||
const saveUsername = async () => {
|
||||
const values = await nameForm.validateFields();
|
||||
setSavingName(true);
|
||||
try {
|
||||
const res = await changeUsername(values.new_username.trim(), values.password);
|
||||
message.success(`用户名已更新为 ${res.username},下次登录请使用新用户名`);
|
||||
onUsernameChanged?.(res.username);
|
||||
nameForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
@@ -518,32 +558,214 @@ function PasswordTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
dependencies={["new_password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入" },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, v) =>
|
||||
!v || v === getFieldValue("new_password")
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("两次输入不一致")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Form form={nameForm} layout="vertical" onFinish={() => void saveUsername()}>
|
||||
<Form.Item
|
||||
name="new_username"
|
||||
label="新用户名"
|
||||
rules={[
|
||||
{ required: true, message: "请输入新用户名" },
|
||||
{ pattern: /^[a-zA-Z0-9_.-]{2,32}$/, message: "2-32 位,仅字母、数字、_ . -" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="字母 / 数字 / _ . -" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="当前密码(确认身份)"
|
||||
rules={[{ required: true, message: "请输入当前密码" }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={savingName}>
|
||||
更新用户名
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
dependencies={["new_password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入" },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, v) =>
|
||||
!v || v === getFieldValue("new_password")
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("两次输入不一致")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: ZITADEL 统一登录表单值(开关单独存 state,避免受控告警) **/
|
||||
interface OidcFormValues {
|
||||
issuer: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* ZITADEL 统一登录 tab(v2.9.0):issuer + Client ID(PKCE 无 secret)+ 启用/JIT 开关;
|
||||
* 回调地址需在 ZITADEL 控制台应用里登记;登录成功即管理员(本平台没有权限设置)
|
||||
*/
|
||||
function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<OidcFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(info.oidc.enabled);
|
||||
const [jit, setJit] = useState(info.oidc.jit);
|
||||
const meta = info.oidc;
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ issuer: meta.issuer, client_id: meta.client_id });
|
||||
setEnabled(meta.enabled);
|
||||
setJit(meta.jit);
|
||||
}, [meta.issuer, meta.client_id, meta.enabled, meta.jit, form]);
|
||||
|
||||
/** 保存统一登录配置 */
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({
|
||||
oidc: { issuer: values.issuer.trim(), client_id: values.client_id.trim(), enabled, jit },
|
||||
});
|
||||
message.success("ZITADEL 统一登录配置已保存");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 清除后台保存的配置 */
|
||||
const clear = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteOidc();
|
||||
message.success("已清除后台保存的配置(env 兜底仍可用)");
|
||||
form.resetFields();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "清除失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type={meta.source === "none" ? "info" : meta.enabled ? "success" : "info"}
|
||||
showIcon
|
||||
message={
|
||||
meta.source === "none"
|
||||
? "尚未配置:在 ZITADEL 控制台(Qiaonao Internal Apps 项目)为 WmailHub 建一个应用(Auth Code + PKCE,无 secret),把 Client ID 填到这里。"
|
||||
: `已配置(来源:${meta.source === "settings" ? "后台设置" : "部署环境变量"})${meta.enabled ? ",登录页已显示「ZITADEL 统一登录」入口。" : ",当前未启用。"}`
|
||||
}
|
||||
description={
|
||||
<>
|
||||
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址;所有通过统一登录登录的用户都是
|
||||
<b>管理员</b>(本平台没有权限设置)。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="回调地址(Redirect URI)">
|
||||
<Typography.Paragraph copyable={{ text: meta.redirect_uri }} className="mono" style={{ marginBottom: 0 }}>
|
||||
{meta.redirect_uri}
|
||||
</Typography.Paragraph>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="issuer"
|
||||
label="Issuer"
|
||||
rules={[{ required: true, message: "请填写 Issuer" }]}
|
||||
extra="如 https://auth.my.qiaonao.net(HTTPS;本机联调允许 http://localhost)"
|
||||
>
|
||||
<Input placeholder="https://auth.my.qiaonao.net" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="client_id"
|
||||
label="Client ID"
|
||||
rules={[{ required: true, message: "请填写 Client ID" }]}
|
||||
extra="ZITADEL 应用的 Client ID;认证方式是 Authorization Code + PKCE,不需要 Client Secret"
|
||||
>
|
||||
<Input placeholder="ZITADEL 应用 Client ID" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label="授权 Scope">
|
||||
<span className="mono">{meta.scopes}</span>{" "}
|
||||
<Typography.Text type="secondary">(固定;userinfo 取身份,实例无 SMTP 拿不到邮箱 claim 也不影响登录)</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item label="启用统一登录" style={{ marginBottom: 8 }}>
|
||||
<Switch checked={enabled} onChange={setEnabled} />
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 12 }}>
|
||||
启用后登录页显示「ZITADEL 统一登录」按钮;密码登录不受影响(双通道并存)
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item label="自动创建本地账号(JIT)" style={{ marginBottom: 8 }}>
|
||||
<Switch checked={jit} onChange={setJit} />
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 12 }}>
|
||||
开启:未绑定的统一登录身份首次登录自动创建本地用户(即管理员);关闭:未绑定身份拒绝登录
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
{meta.source === "settings" && (
|
||||
<Popconfirm
|
||||
title="清除后台保存的统一登录配置?"
|
||||
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口;已建立的绑定保留。"
|
||||
onConfirm={() => void clear()}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={saving}>
|
||||
清除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<Card size="small" title="当前账号绑定" style={{ width: "100%" }}>
|
||||
{meta.identity ? (
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="身份">
|
||||
<Tag color="green">已绑定</Tag>
|
||||
<span className="mono">{meta.identity.display_name ?? "(无展示名)"}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Subject">
|
||||
<span className="mono">{meta.identity.subject}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Issuer">
|
||||
<span className="mono">{meta.identity.issuer}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">
|
||||
{meta.identity.email ? <span className="mono">{meta.identity.email}</span> : <Typography.Text type="secondary">未提供(IdP 未配置 SMTP,正常现象)</Typography.Text>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
当前账号未绑定统一登录身份;绑定后可直接用 ZITADEL 登录本站(启用 JIT 时首次登录会自动建新账号)。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
/** 登录页:用户名密码(默认 admin/admin) */
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Card, Form, Input, App } from "antd";
|
||||
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { login } from "../api.ts";
|
||||
/** 登录页:用户名密码(默认 admin/admin)+ ZITADEL 统一登录(启用时显示,v2.9.0) */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Divider, Form, Input, App } from "antd";
|
||||
import { LockOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { login, oidcStatus } from "../api.ts";
|
||||
|
||||
/** # 成员变量: 统一登录错误码 -> 中文文案(与后端 routes/oidc.ts 的 OIDC_ERROR_TEXT 保持一致) **/
|
||||
const OIDC_ERROR_TEXT: Record<string, string> = {
|
||||
oidc_disabled: "统一登录未启用",
|
||||
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
|
||||
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
|
||||
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
|
||||
oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 登录页组件
|
||||
@@ -11,7 +20,17 @@ import { login } from "../api.ts";
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [params] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidc, setOidc] = useState<{ enabled: boolean; issuer: string | null }>({ enabled: false, issuer: null });
|
||||
const oidcError = params.get("error");
|
||||
|
||||
useEffect(() => {
|
||||
// 探测统一登录是否启用(公开接口;失败静默,不影响密码登录)
|
||||
void oidcStatus()
|
||||
.then(setOidc)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
@@ -23,6 +42,14 @@ export function LoginPage() {
|
||||
<div style={{ textAlign: "center", marginBottom: 20 }} className="muted">
|
||||
Cloudflare Email Workers 邮件接收平台
|
||||
</div>
|
||||
{oidcError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={OIDC_ERROR_TEXT[oidcError] ?? `统一登录失败(${oidcError})`}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={async (values) => {
|
||||
@@ -48,6 +75,25 @@ export function LoginPage() {
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc.enabled && (
|
||||
<>
|
||||
<Divider plain style={{ margin: "18px 0 14px" }}>
|
||||
或
|
||||
</Divider>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<SafetyOutlined />}
|
||||
title={oidc.issuer ? `通过 ${oidc.issuer} 统一登录` : "ZITADEL 统一登录"}
|
||||
onClick={() => {
|
||||
// 登录成功后统一回 /(当前在登录页,必然是未登录状态;return 缺省即 /)
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
>
|
||||
ZITADEL 统一登录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 14, textAlign: "center" }} className="muted">
|
||||
默认账号 admin / admin,登录后请尽快修改
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,8 @@ const TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
mailbox: { label: "邮箱", color: "purple" },
|
||||
email: { label: "邮件", color: "orange" },
|
||||
webhook: { label: "Webhook", color: "blue" },
|
||||
user: { label: "用户", color: "volcano" },
|
||||
settings: { label: "设置", color: "default" },
|
||||
};
|
||||
|
||||
/** # 成员变量: 动作名 -> 中文标签(未收录的动作原样展示) **/
|
||||
@@ -40,6 +42,11 @@ const ACTION_LABEL: Record<string, string> = {
|
||||
"webhook.clear": "清除 Webhook",
|
||||
"webhook.deliver": "Webhook 投递成功",
|
||||
"webhook.deliver_failed": "Webhook 投递失败",
|
||||
"user.rename": "修改用户名",
|
||||
"user.oidc_login": "统一登录",
|
||||
"user.oidc_signup": "统一登录(自动建号)",
|
||||
"settings.oidc": "配置统一登录",
|
||||
"settings.oidc_clear": "清除统一登录配置",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
|
||||
Reference in New Issue
Block a user