diff --git a/CHANGELOG b/CHANGELOG
index 7d4ef9a52b..261b53e8a3 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,9 @@
+## [100.1.4] - 2026-08-18
+
+### Added
+
+- 支持标准 OIDC 登录,并映射到现有用户系统(收藏、播放记录、角色权限保持不变)
+
## [100.1.3] - 2026-05-28
### Fixed
diff --git a/README.md b/README.md
index ca35b810b3..cef4f9ddc8 100644
--- a/README.md
+++ b/README.md
@@ -341,6 +341,28 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
+| OIDC_ISSUER | OIDC 发行方地址(配置后自动启用 OIDC) | `https://auth.example.com` 或完整 discovery URL | 空(不启用) |
+| OIDC_CLIENT_ID | OIDC 客户端 ID | 字符串 | 空 |
+| OIDC_CLIENT_SECRET | OIDC 客户端密钥(机密客户端需要) | 字符串 | 空 |
+| OIDC_REDIRECT_URI | OIDC 回调地址 | 完整 URL | `{SITE_BASE}/api/oidc/callback` |
+| OIDC_SCOPES | OIDC 授权范围 | 空格分隔 | `openid profile email` |
+| OIDC_USERNAME_CLAIM | 映射到 LunaTV 用户名的 claim | `preferred_username` / `email` / `sub` 等 | `preferred_username` |
+| OIDC_AUTO_REGISTER | 首次 OIDC 登录是否自动创建本地用户 | true/false | true |
+| OIDC_DISABLE_PASSWORD | 启用 OIDC 后是否隐藏账号密码登录 | true/false | false |
+| OIDC_BUTTON_TEXT | 登录页 OIDC 按钮文案 | 任意字符串 | OIDC 登录 |
+| OIDC_ENABLED | 强制开关(一般无需设置) | true/false | 配置了 ISSUER + CLIENT_ID 即为 true |
+| OIDC_CLIENT_AUTH | 令牌端点客户端认证方式 | basic / post / none | 按 IdP 发现文档自动选择,缺省 post |
+
+OIDC 说明:
+
+- **仅支持** `redis` / `kvrocks` / `upstash` 多用户存储,不支持 `localstorage` 单密码模式
+- 请在身份提供方登记回调地址:`https://你的站点/api/oidc/callback`,并设置相同的 `SITE_BASE` 或 `OIDC_REDIRECT_URI`。反代 / HTTPS / 非 localhost 访问时必须显式配置,否则换票会因回调地址不一致失败
+- 机密客户端必须配置 `OIDC_CLIENT_SECRET`。若 IdP 只接受 Basic 认证,可设 `OIDC_CLIENT_AUTH=basic`
+- OIDC 用户会进入现有用户系统:可在管理面板封禁、提权、分配用户组;收藏和播放记录按用户名同步
+- 用户名优先取 `OIDC_USERNAME_CLAIM`,缺省回退 `preferred_username` → `email` → `name` → `sub`
+- 若解析出的用户名等于 `USERNAME`,将以站长身份登录
+- 已存在的同名本地用户会自动绑定 OIDC `sub`,之后即使 IdP 用户名变更也按 `sub` 识别
+- 建议把用户范围限制在可信 IdP 内;若只允许预创建账号登录,设置 `OIDC_AUTO_REGISTER=false`
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
diff --git a/package.json b/package.json
index ee4f82f261..7b22356d54 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.10",
+ "jose": "5.10.0",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b679f62d46..4dc11ac375 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,9 @@ importers:
hls.js:
specifier: ^1.6.10
version: 1.6.10
+ jose:
+ specifier: 5.10.0
+ version: 5.10.0
lucide-react:
specifier: ^0.438.0
version: 0.438.0(react@18.3.1)
@@ -3553,6 +3556,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
+ jose@5.10.0:
+ resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
+
js-string-escape@1.0.1:
resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
engines: {node: '>= 0.8'}
@@ -9486,6 +9492,8 @@ snapshots:
jiti@1.21.7: {}
+ jose@5.10.0: {}
+
js-string-escape@1.0.1: {}
js-tokens@4.0.0: {}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 830fffee7d..9f8ed38488 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1139,7 +1139,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
)}
- {user.username}
+
+ {user.username}
+ {user.from === 'oidc' && (
+
+ OIDC
+
+ )}
+
|
{
- const encoder = new TextEncoder();
- const keyData = encoder.encode(secret);
- const messageData = encoder.encode(data);
-
- // 导入密钥
- const key = await crypto.subtle.importKey(
- 'raw',
- keyData,
- { name: 'HMAC', hash: 'SHA-256' },
- false,
- ['sign']
- );
-
- // 生成签名
- const signature = await crypto.subtle.sign('HMAC', key, messageData);
-
- // 转换为十六进制字符串
- return Array.from(new Uint8Array(signature))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-}
-
-// 生成认证Cookie(带签名)
-async function generateAuthCookie(
- username?: string,
- password?: string,
- role?: 'owner' | 'admin' | 'user',
- includePassword = false
-): Promise {
- const authData: any = { role: role || 'user' };
-
- // 只在需要时包含 password
- if (includePassword && password) {
- authData.password = password;
- }
-
- if (username && process.env.PASSWORD) {
- authData.username = username;
- // 使用密码作为密钥对用户名进行签名
- const signature = await generateSignature(username, process.env.PASSWORD);
- authData.signature = signature;
- authData.timestamp = Date.now(); // 添加时间戳防重放攻击
- }
-
- return encodeURIComponent(JSON.stringify(authData));
-}
-
export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
@@ -78,13 +27,7 @@ export async function POST(req: NextRequest) {
const response = NextResponse.json({ ok: true });
// 清除可能存在的认证cookie
- response.cookies.set('auth', '', {
- path: '/',
- expires: new Date(0),
- sameSite: 'lax', // 改为 lax 以支持 PWA
- httpOnly: false, // PWA 需要客户端可访问
- secure: false, // 根据协议自动设置
- });
+ clearAuthCookie(response);
return response;
}
@@ -107,18 +50,10 @@ export async function POST(req: NextRequest) {
undefined,
password,
'user',
- true
+ true,
+ 'password'
); // localstorage 模式包含 password
- const expires = new Date();
- expires.setDate(expires.getDate() + 7); // 7天过期
-
- response.cookies.set('auth', cookieValue, {
- path: '/',
- expires,
- sameSite: 'lax', // 改为 lax 以支持 PWA
- httpOnly: false, // PWA 需要客户端可访问
- secure: false, // 根据协议自动设置
- });
+ applyAuthCookie(response, cookieValue);
return response;
}
@@ -144,18 +79,10 @@ export async function POST(req: NextRequest) {
username,
password,
'owner',
- false
+ false,
+ 'password'
); // 数据库模式不包含 password
- const expires = new Date();
- expires.setDate(expires.getDate() + 7); // 7天过期
-
- response.cookies.set('auth', cookieValue, {
- path: '/',
- expires,
- sameSite: 'lax', // 改为 lax 以支持 PWA
- httpOnly: false, // PWA 需要客户端可访问
- secure: false, // 根据协议自动设置
- });
+ applyAuthCookie(response, cookieValue);
return response;
} else if (username === process.env.USERNAME) {
@@ -184,18 +111,10 @@ export async function POST(req: NextRequest) {
username,
password,
user?.role || 'user',
- false
+ false,
+ 'password'
); // 数据库模式不包含 password
- const expires = new Date();
- expires.setDate(expires.getDate() + 7); // 7天过期
-
- response.cookies.set('auth', cookieValue, {
- path: '/',
- expires,
- sameSite: 'lax', // 改为 lax 以支持 PWA
- httpOnly: false, // PWA 需要客户端可访问
- secure: false, // 根据协议自动设置
- });
+ applyAuthCookie(response, cookieValue);
return response;
} catch (err) {
diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts
index 8bfa015f08..ecba796ce6 100644
--- a/src/app/api/logout/route.ts
+++ b/src/app/api/logout/route.ts
@@ -1,18 +1,33 @@
import { NextResponse } from 'next/server';
+import { clearAuthCookie } from '@/lib/auth';
+import {
+ OIDC_CALLBACK_URI_COOKIE,
+ OIDC_FLOW_COOKIE_BASE,
+ OIDC_NONCE_COOKIE,
+ OIDC_REDIRECT_COOKIE,
+ OIDC_STATE_COOKIE,
+ OIDC_VERIFIER_COOKIE,
+} from '@/lib/oidc';
+
export const runtime = 'nodejs';
export async function POST() {
const response = NextResponse.json({ ok: true });
// 清除认证cookie
- response.cookies.set('auth', '', {
- path: '/',
+ clearAuthCookie(response);
+
+ const expired = {
+ ...OIDC_FLOW_COOKIE_BASE,
+ maxAge: 0,
expires: new Date(0),
- sameSite: 'lax', // 改为 lax 以支持 PWA
- httpOnly: false, // PWA 需要客户端可访问
- secure: false, // 根据协议自动设置
- });
+ };
+ response.cookies.set(OIDC_STATE_COOKIE, '', expired);
+ response.cookies.set(OIDC_NONCE_COOKIE, '', expired);
+ response.cookies.set(OIDC_VERIFIER_COOKIE, '', expired);
+ response.cookies.set(OIDC_REDIRECT_COOKIE, '', expired);
+ response.cookies.set(OIDC_CALLBACK_URI_COOKIE, '', expired);
return response;
}
diff --git a/src/app/api/oidc/callback/route.ts b/src/app/api/oidc/callback/route.ts
new file mode 100644
index 0000000000..f682da79a7
--- /dev/null
+++ b/src/app/api/oidc/callback/route.ts
@@ -0,0 +1,146 @@
+/* eslint-disable no-console */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { applyAuthCookie, generateAuthCookie } from '@/lib/auth';
+import {
+ exchangeAuthorizationCode,
+ fetchUserInfo,
+ getOidcDiscovery,
+ getPublicBaseUrl,
+ isMultiUserStorage,
+ isOidcConfigured,
+ OIDC_CALLBACK_URI_COOKIE,
+ OIDC_FLOW_COOKIE_BASE,
+ OIDC_NONCE_COOKIE,
+ OIDC_REDIRECT_COOKIE,
+ OIDC_STATE_COOKIE,
+ OIDC_VERIFIER_COOKIE,
+ OidcError,
+ oidcLoginErrorPath,
+ provisionOidcUser,
+ resolveStoredRedirectUri,
+ sanitizeRedirect,
+ verifyIdToken,
+} from '@/lib/oidc';
+
+export const runtime = 'nodejs';
+
+function clearOidcFlowCookies(response: NextResponse) {
+ const expired = {
+ ...OIDC_FLOW_COOKIE_BASE,
+ maxAge: 0,
+ expires: new Date(0),
+ };
+ response.cookies.set(OIDC_STATE_COOKIE, '', expired);
+ response.cookies.set(OIDC_NONCE_COOKIE, '', expired);
+ response.cookies.set(OIDC_VERIFIER_COOKIE, '', expired);
+ response.cookies.set(OIDC_REDIRECT_COOKIE, '', expired);
+ response.cookies.set(OIDC_CALLBACK_URI_COOKIE, '', expired);
+}
+
+function redirectToLogin(
+ request: NextRequest,
+ code: string,
+ fallbackRedirect?: string,
+ detail?: string
+): NextResponse {
+ const response = NextResponse.redirect(
+ new URL(
+ oidcLoginErrorPath(code, fallbackRedirect, detail),
+ `${getPublicBaseUrl(request)}/`
+ )
+ );
+ clearOidcFlowCookies(response);
+ return response;
+}
+
+export async function GET(request: NextRequest) {
+ const storedRedirect = sanitizeRedirect(
+ request.cookies.get(OIDC_REDIRECT_COOKIE)?.value
+ );
+ const storedState = request.cookies.get(OIDC_STATE_COOKIE)?.value;
+ const storedNonce = request.cookies.get(OIDC_NONCE_COOKIE)?.value;
+ const storedVerifier = request.cookies.get(OIDC_VERIFIER_COOKIE)?.value;
+
+ const idpError = request.nextUrl.searchParams.get('error');
+ if (idpError) {
+ const denied =
+ idpError === 'access_denied' ? 'oidc_denied' : 'oidc_failed';
+ return redirectToLogin(request, denied, storedRedirect);
+ }
+
+ try {
+ if (!isOidcConfigured() || !isMultiUserStorage()) {
+ throw new OidcError('oidc_not_configured', '未启用 OIDC');
+ }
+ if (!process.env.PASSWORD) {
+ throw new OidcError('oidc_not_configured', '未配置 PASSWORD,无法签发会话');
+ }
+
+ const code = request.nextUrl.searchParams.get('code');
+ const state = request.nextUrl.searchParams.get('state');
+ if (!code || !state) {
+ throw new OidcError('oidc_state', '缺少授权码或 state');
+ }
+ if (!storedState || storedState !== state) {
+ throw new OidcError('oidc_state', 'OIDC state 校验失败');
+ }
+ if (!storedNonce || !storedVerifier) {
+ throw new OidcError('oidc_state', 'OIDC 登录会话已过期,请重试');
+ }
+
+ const discovery = await getOidcDiscovery();
+ const redirectUri = resolveStoredRedirectUri(
+ request,
+ request.cookies.get(OIDC_CALLBACK_URI_COOKIE)?.value
+ );
+ const tokens = await exchangeAuthorizationCode({
+ discovery,
+ code,
+ redirectUri,
+ codeVerifier: storedVerifier,
+ });
+
+ if (!tokens.id_token) {
+ throw new OidcError(
+ 'oidc_token',
+ '令牌响应缺少 id_token',
+ '身份提供方未返回 id_token,请确认 scope 包含 openid'
+ );
+ }
+
+ let claims = await verifyIdToken(tokens.id_token, discovery, storedNonce);
+
+ const needUserInfo =
+ !claims[process.env.OIDC_USERNAME_CLAIM || 'preferred_username'] &&
+ !claims.preferred_username &&
+ !claims.email &&
+ !claims.name;
+ if (needUserInfo && tokens.access_token) {
+ const extra = await fetchUserInfo(discovery, tokens.access_token);
+ claims = { ...extra, ...claims };
+ }
+
+ const identity = await provisionOidcUser(claims);
+ const cookieValue = await generateAuthCookie(
+ identity.username,
+ undefined,
+ identity.role,
+ false,
+ 'oidc'
+ );
+
+ const response = NextResponse.redirect(
+ new URL(storedRedirect, `${getPublicBaseUrl(request)}/`)
+ );
+ applyAuthCookie(response, cookieValue);
+ clearOidcFlowCookies(response);
+ return response;
+ } catch (error) {
+ const code = error instanceof OidcError ? error.code : 'oidc_failed';
+ const detail = error instanceof OidcError ? error.detail : undefined;
+ console.error('OIDC 回调处理失败:', error);
+ return redirectToLogin(request, code, storedRedirect, detail);
+ }
+}
diff --git a/src/app/api/oidc/login/route.ts b/src/app/api/oidc/login/route.ts
new file mode 100644
index 0000000000..e0b70ba7c2
--- /dev/null
+++ b/src/app/api/oidc/login/route.ts
@@ -0,0 +1,85 @@
+/* eslint-disable no-console */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import {
+ buildAuthorizationUrl,
+ createPkcePair,
+ generateOidcRandomString,
+ getOidcDiscovery,
+ getOidcRedirectUri,
+ getPublicBaseUrl,
+ isMultiUserStorage,
+ isOidcConfigured,
+ OIDC_CALLBACK_URI_COOKIE,
+ OIDC_FLOW_COOKIE_BASE,
+ OIDC_NONCE_COOKIE,
+ OIDC_REDIRECT_COOKIE,
+ OIDC_STATE_COOKIE,
+ OIDC_VERIFIER_COOKIE,
+ OidcError,
+ oidcLoginErrorPath,
+ sanitizeRedirect,
+} from '@/lib/oidc';
+
+export const runtime = 'nodejs';
+
+export async function GET(request: NextRequest) {
+ const requestedRedirect = sanitizeRedirect(
+ request.nextUrl.searchParams.get('redirect')
+ );
+
+ try {
+ if (!isOidcConfigured()) {
+ throw new OidcError('oidc_not_configured', '未启用 OIDC');
+ }
+ if (!isMultiUserStorage()) {
+ throw new OidcError(
+ 'oidc_storage',
+ 'OIDC 仅支持 redis / kvrocks / upstash 存储模式'
+ );
+ }
+ if (!process.env.PASSWORD) {
+ throw new OidcError('oidc_not_configured', '未配置 PASSWORD,无法签发会话');
+ }
+
+ const discovery = await getOidcDiscovery();
+ const redirectUri = getOidcRedirectUri(request);
+ const state = generateOidcRandomString(24);
+ const nonce = generateOidcRandomString(24);
+ const { verifier, challenge } = createPkcePair();
+ const authorizationUrl = buildAuthorizationUrl({
+ discovery,
+ redirectUri,
+ state,
+ nonce,
+ codeChallenge: challenge,
+ });
+
+ const response = NextResponse.redirect(authorizationUrl);
+ response.cookies.set(OIDC_STATE_COOKIE, state, OIDC_FLOW_COOKIE_BASE);
+ response.cookies.set(OIDC_NONCE_COOKIE, nonce, OIDC_FLOW_COOKIE_BASE);
+ response.cookies.set(OIDC_VERIFIER_COOKIE, verifier, OIDC_FLOW_COOKIE_BASE);
+ response.cookies.set(
+ OIDC_REDIRECT_COOKIE,
+ requestedRedirect,
+ OIDC_FLOW_COOKIE_BASE
+ );
+ response.cookies.set(
+ OIDC_CALLBACK_URI_COOKIE,
+ redirectUri,
+ OIDC_FLOW_COOKIE_BASE
+ );
+ return response;
+ } catch (error) {
+ const code =
+ error instanceof OidcError ? error.code : 'oidc_failed';
+ console.error('OIDC 登录发起失败:', error);
+ return NextResponse.redirect(
+ new URL(
+ oidcLoginErrorPath(code, requestedRedirect),
+ `${getPublicBaseUrl(request)}/`
+ )
+ );
+ }
+}
diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts
index 011a0ffb22..da185d5c71 100644
--- a/src/app/api/server-config/route.ts
+++ b/src/app/api/server-config/route.ts
@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
+import { getPublicOidcConfig } from '@/lib/oidc';
import { CURRENT_VERSION } from '@/lib/version'
export const runtime = 'nodejs';
@@ -15,6 +16,7 @@ export async function GET(request: NextRequest) {
SiteName: config.SiteConfig.SiteName,
StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
Version: CURRENT_VERSION,
+ Oidc: getPublicOidcConfig(),
};
return NextResponse.json(result);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 51ad3a93c9..81fb63dfce 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { Inter } from 'next/font/google';
import './globals.css';
import { getConfig } from '@/lib/config';
+import { getPublicOidcConfig } from '@/lib/oidc';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
@@ -92,6 +93,7 @@ export default async function RootLayout({
CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch,
ENABLE_WEB_LIVE: enableWebLive,
+ OIDC: getPublicOidcConfig(),
};
return (
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index a0f2e72991..e3a3cae7ae 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -67,6 +67,20 @@ function VersionDisplay() {
);
}
+const OIDC_ERROR_MESSAGES: Record = {
+ oidc_not_configured: 'OIDC 未正确配置',
+ oidc_storage: '当前存储模式不支持 OIDC,请使用 redis / kvrocks / upstash',
+ oidc_discovery: '无法连接身份提供方,请检查 OIDC_ISSUER',
+ oidc_state: '登录状态已失效,请重新发起 OIDC 登录',
+ oidc_nonce: 'OIDC 校验失败,请重试',
+ oidc_denied: '已取消 OIDC 登录',
+ oidc_banned: '该账号已被封禁',
+ oidc_not_registered: '账号未开通,请联系管理员先创建用户',
+ oidc_username: '无法从身份提供方解析用户名',
+ oidc_token: 'OIDC 换取令牌失败,请检查回调地址、Client Secret 和客户端认证方式',
+ oidc_failed: 'OIDC 登录失败,请稍后重试',
+};
+
function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -75,21 +89,41 @@ function LoginPageClient() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [shouldAskUsername, setShouldAskUsername] = useState(false);
+ const [oidcEnabled, setOidcEnabled] = useState(false);
+ const [oidcButtonText, setOidcButtonText] = useState('OIDC 登录');
+ const [disablePassword, setDisablePassword] = useState(false);
+ const [oidcLoading, setOidcLoading] = useState(false);
const { siteName } = useSite();
// 在客户端挂载后设置配置
useEffect(() => {
if (typeof window !== 'undefined') {
- const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
+ const runtimeConfig = (window as any).RUNTIME_CONFIG;
+ const storageType = runtimeConfig?.STORAGE_TYPE;
setShouldAskUsername(storageType && storageType !== 'localstorage');
+ setOidcEnabled(Boolean(runtimeConfig?.OIDC?.enabled));
+ if (runtimeConfig?.OIDC?.buttonText) {
+ setOidcButtonText(runtimeConfig.OIDC.buttonText);
+ }
+ setDisablePassword(Boolean(runtimeConfig?.OIDC?.disablePassword));
}
}, []);
+ useEffect(() => {
+ const oidcError = searchParams.get('error');
+ if (oidcError) {
+ const detail = searchParams.get('error_detail');
+ const base = OIDC_ERROR_MESSAGES[oidcError] || 'OIDC 登录失败,请稍后重试';
+ setError(detail ? `${base}(${detail})` : base);
+ }
+ }, [searchParams]);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
+ if (disablePassword && oidcEnabled) return;
if (!password || (shouldAskUsername && !username)) return;
try {
@@ -119,7 +153,18 @@ function LoginPageClient() {
}
};
+ const handleOidcLogin = () => {
+ setError(null);
+ setOidcLoading(true);
+ const redirect = searchParams.get('redirect') || '/';
+ const oidcUrl = new URL('/api/oidc/login', window.location.origin);
+ if (redirect && redirect !== '/') {
+ oidcUrl.searchParams.set('redirect', redirect);
+ }
+ window.location.href = oidcUrl.toString();
+ };
+ const showPasswordForm = !oidcEnabled || !disablePassword;
return (
@@ -131,52 +176,82 @@ function LoginPageClient() {
{siteName}
|