mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
OIDC
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { OAuthCallback } from '@/features/auth/components/oauth-callback';
|
||||
|
||||
export default function OAuthSourceCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<OAuthCallback />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OAuthLinkExistingForm } from '@/features/auth/components/oauth-link-existing-form';
|
||||
|
||||
export default function OAuthLinkPage() {
|
||||
return <OAuthLinkExistingForm />;
|
||||
}
|
||||
@@ -1,60 +1,100 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout');
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload, turnstileToken?: string) {
|
||||
const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
|
||||
|
||||
return apiRequest<void>(`/user/register${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout');
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload, turnstileToken?: string) {
|
||||
const query = turnstileToken
|
||||
? `?turnstile=${encodeURIComponent(turnstileToken)}`
|
||||
: '';
|
||||
|
||||
return apiRequest<void>(`/user/register${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResult {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'registered' | 'linked' | 'link_required';
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export interface LinkExistingOAuthPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizeUrl(source: number | string) {
|
||||
return apiRequest<OAuthAuthorizeResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/authorize`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeOAuthCode(
|
||||
source: number | string,
|
||||
code: string,
|
||||
state: string,
|
||||
) {
|
||||
const searchParams = new URLSearchParams({ code, state });
|
||||
return apiRequest<OAuthCallbackResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
|
||||
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { z } from 'zod';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { login } from '@/features/auth/api/auth';
|
||||
import { getOAuthAuthorizeUrl, login } from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
AuthButton,
|
||||
@@ -24,14 +24,14 @@ import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||
const TEXT = {
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||
loginFailed: '\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
githubUnavailable: 'GitHub \u767b\u5f55\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
|
||||
loginFailed:
|
||||
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
oauthUnavailable: '第三方登录当前不可用。',
|
||||
title: '\u7528\u6237\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
password: '\u5bc6\u7801',
|
||||
loginPending: '\u767b\u5f55\u4e2d...',
|
||||
login: '\u767b\u5f55',
|
||||
githubLogin: 'GitHub \u767b\u5f55',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
register: '\u6ce8\u518c',
|
||||
};
|
||||
@@ -78,35 +78,37 @@ export function LoginForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: getOAuthAuthorizeUrl,
|
||||
onSuccess: (result) => {
|
||||
window.location.href = result.authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.oauthUnavailable);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
loginMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleGitHubLogin = () => {
|
||||
const clientId = statusQuery.data?.github_client_id;
|
||||
if (!clientId) {
|
||||
setErrorMessage(TEXT.githubUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('scope', 'user:email');
|
||||
window.location.href = authorizeUrl.toString();
|
||||
const handleOAuthLogin = (sourceName: string) => {
|
||||
setErrorMessage('');
|
||||
oauthMutation.mutate(sourceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title}>
|
||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -114,41 +116,53 @@ export function LoginForm() {
|
||||
|
||||
<AuthFormField label={TEXT.password}>
|
||||
<AuthInput
|
||||
type='password'
|
||||
type="password"
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone='danger' message={errorMessage} />
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<AuthButton type='submit' disabled={loginMutation.isPending}>
|
||||
<div>
|
||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||
</AuthButton>
|
||||
{statusQuery.data?.github_oauth ? (
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={handleGitHubLogin}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{TEXT.githubLogin}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
第三方账号登录
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(statusQuery.data?.auth_sources ?? []).map((source) => (
|
||||
<SecondaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(source.name)}
|
||||
className="min-w-36"
|
||||
disabled={oauthMutation.isPending}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href='/reset'
|
||||
className='text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
href="/reset"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
@@ -156,8 +170,8 @@ export function LoginForm() {
|
||||
<>
|
||||
<span>|</span>
|
||||
<Link
|
||||
href='/register'
|
||||
className='text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
href="/register"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.register}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { exchangeOAuthCode } from '@/features/auth/api/auth';
|
||||
|
||||
function parseOAuthSource(pathname: string | null, sourceParam: string) {
|
||||
const normalizedParam = sourceParam.trim();
|
||||
if (normalizedParam) {
|
||||
return normalizedParam;
|
||||
}
|
||||
|
||||
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
|
||||
if (!pathMatch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(pathMatch[1]);
|
||||
}
|
||||
|
||||
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledRef = useRef('');
|
||||
const [prompt, setPrompt] = useState('正在处理授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
const state = searchParams?.get('state')?.trim() ?? '';
|
||||
const oauthError = searchParams?.get('error')?.trim() ?? '';
|
||||
const oauthErrorDescription =
|
||||
searchParams?.get('error_description')?.trim() ?? '';
|
||||
const resolvedSource =
|
||||
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'link_required') {
|
||||
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
|
||||
router.replace('/oauth/link');
|
||||
return;
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthError || oauthErrorDescription) {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: oauthErrorDescription || oauthError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resolvedSource).trim() === '') {
|
||||
setPrompt('缺少认证源参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到认证源参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setPrompt('缺少授权参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到完整授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${resolvedSource}:${code}:${state}`;
|
||||
if (handledRef.current === key) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = key;
|
||||
mutation.mutate();
|
||||
}, [
|
||||
code,
|
||||
mutation,
|
||||
oauthError,
|
||||
oauthErrorDescription,
|
||||
resolvedSource,
|
||||
state,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppCard title="第三方登录回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
{message?.tone === 'danger' ? (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { linkExistingOAuthAccount } from '@/features/auth/api/auth';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OAuthLinkExistingForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: linkExistingOAuthAccount,
|
||||
onSuccess: (result) => {
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || '绑定失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="绑定已有账号"
|
||||
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="用户名">
|
||||
<AuthInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label="密码">
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '绑定中...' : '绑定并登录'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
GeoIPLookupResult,
|
||||
@@ -43,6 +45,37 @@ export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthSources() {
|
||||
return apiRequest<AuthSource[]>('/auth-sources/');
|
||||
}
|
||||
|
||||
export function createAuthSource(payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>('/auth-sources/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAuthSource(id: number, payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>(`/auth-sources/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleAuthSource(id: number, isActive: boolean) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/toggle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_active: isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAuthSource(id: number) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
createAuthSource,
|
||||
deleteAuthSource,
|
||||
toggleAuthSource,
|
||||
updateAuthSource,
|
||||
} from '@/features/settings/api/settings';
|
||||
import type {
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
AuthSourceType,
|
||||
} from '@/features/settings/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const emptyForm: AuthSourcePayload = {
|
||||
name: '',
|
||||
type: 'github',
|
||||
display_name: '',
|
||||
is_active: false,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
openid_discovery_url: '',
|
||||
scopes: 'user:email',
|
||||
icon_url: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function sourceToForm(source: AuthSource): AuthSourcePayload {
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
type: source.type,
|
||||
display_name: source.display_name,
|
||||
is_active: source.is_active,
|
||||
client_id: source.client_id,
|
||||
client_secret: '',
|
||||
openid_discovery_url: source.openid_discovery_url,
|
||||
scopes:
|
||||
source.scopes ||
|
||||
(source.type === 'oidc' ? 'openid profile email' : 'user:email'),
|
||||
icon_url: source.icon_url,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCallbackURL(origin: string, sourceName: string) {
|
||||
const normalizedName = sourceName.trim() || '认证源名称';
|
||||
return `${origin || '当前访问地址'}/oauth/${encodeURIComponent(normalizedName)}`;
|
||||
}
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
sources,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
sources: AuthSource[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'list' | 'edit'>('list');
|
||||
const [editingSource, setEditingSource] = useState<AuthSource | null>(null);
|
||||
const [form, setForm] = useState<AuthSourcePayload>(emptyForm);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [browserOrigin, setBrowserOrigin] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setBusyKey(null);
|
||||
setMessage(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setBrowserOrigin(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => {
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const startEdit = (source: AuthSource) => {
|
||||
setEditingSource(source);
|
||||
setForm(sourceToForm(source));
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const updateType = (type: AuthSourceType) => {
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
type,
|
||||
scopes:
|
||||
previous.scopes === 'user:email' ||
|
||||
previous.scopes === 'openid profile email' ||
|
||||
!previous.scopes
|
||||
? type === 'oidc'
|
||||
? 'openid profile email'
|
||||
: 'user:email'
|
||||
: previous.scopes,
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
setBusyKey(key);
|
||||
setMessage(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (actionError) {
|
||||
setMessage({ tone: 'danger', text: getErrorMessage(actionError) });
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveForm = () => {
|
||||
void runAction('save', async () => {
|
||||
const payload: AuthSourcePayload = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
client_id: form.client_id.trim(),
|
||||
client_secret: form.client_secret.trim(),
|
||||
openid_discovery_url: form.openid_discovery_url.trim(),
|
||||
scopes: form.scopes.trim(),
|
||||
icon_url: form.icon_url.trim(),
|
||||
};
|
||||
if (editingSource) {
|
||||
await updateAuthSource(editingSource.id, payload);
|
||||
} else {
|
||||
await createAuthSource(payload);
|
||||
}
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已保存。' });
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (source: AuthSource) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定删除认证源「${source.display_name || source.name}」吗?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void runAction(`delete-${source.id}`, async () => {
|
||||
await deleteAuthSource(source.id);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已删除。' });
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSource = (source: AuthSource) => {
|
||||
void runAction(`toggle-${source.id}`, async () => {
|
||||
await toggleAuthSource(source.id, !source.is_active);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源状态已更新。' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
title="认证源"
|
||||
description="配置 GitHub 或标准 OIDC 登录入口。启用后会显示在登录页。"
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
mode === 'edit' ? (
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => setMode('list')}>
|
||||
返回列表
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={saveForm}
|
||||
disabled={busyKey === 'save'}
|
||||
>
|
||||
{busyKey === 'save' ? '保存中...' : '保存认证源'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton type="button" onClick={startCreate}>
|
||||
新增认证源
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="认证源加载失败"
|
||||
description={getErrorMessage(error)}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
|
||||
暂无认证源。
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">名称</th>
|
||||
<th className="px-4 py-3 font-medium">类型</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">Client ID</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[var(--foreground-primary)]">
|
||||
{source.display_name || source.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
{source.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 uppercase">{source.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
{source.is_active ? '已启用' : '已禁用'}
|
||||
</td>
|
||||
<td className="max-w-[220px] truncate px-4 py-3">
|
||||
{source.client_id || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => toggleSource(source)}
|
||||
disabled={busyKey === `toggle-${source.id}`}
|
||||
>
|
||||
{source.is_active ? '禁用' : '启用'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => startEdit(source)}
|
||||
>
|
||||
修改
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => removeSource(source)}
|
||||
disabled={busyKey === `delete-${source.id}`}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="认证源名称">
|
||||
<ResourceInput
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="展示名称">
|
||||
<ResourceInput
|
||||
value={form.display_name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
display_name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="类型">
|
||||
<ResourceSelect
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
updateType(event.target.value as AuthSourceType)
|
||||
}
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="oidc">OIDC</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="状态">
|
||||
<ResourceSelect
|
||||
value={form.is_active ? 'true' : 'false'}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
is_active: event.target.value === 'true',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="false">禁用</option>
|
||||
<option value="true">启用</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="Client ID">
|
||||
<ResourceInput
|
||||
value={form.client_id}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_id: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="Client Secret"
|
||||
hint={editingSource ? '留空表示不更新现有密钥。' : undefined}
|
||||
>
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={form.client_secret}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_secret: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="md:col-span-2 rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)]">
|
||||
第三方平台的 Redirect URI / Callback URL 请填写:
|
||||
<span className="font-medium">
|
||||
{' '}
|
||||
{buildCallbackURL(browserOrigin, form.name)}
|
||||
</span>
|
||||
。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。
|
||||
</div>
|
||||
{form.type === 'oidc' ? (
|
||||
<ResourceField
|
||||
label="OIDC Discovery URL"
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<ResourceInput
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
openid_discovery_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://auth.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
<ResourceField label="Scopes">
|
||||
<ResourceInput
|
||||
value={form.scopes}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
scopes: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="图标 URL">
|
||||
<ResourceInput
|
||||
value={form.icon_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
icon_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,16 @@ import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { sendEmailVerification } from '@/features/auth/api/auth';
|
||||
import {
|
||||
getOAuthAuthorizeUrl,
|
||||
sendEmailVerification,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
bindEmail,
|
||||
bindWeChat,
|
||||
cleanupDatabaseObservability,
|
||||
generateAccessToken,
|
||||
getAuthSources,
|
||||
getBootstrapToken,
|
||||
getOptions,
|
||||
getSettingsProfile,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
updateOptions,
|
||||
updateSelf,
|
||||
} from '@/features/settings/api/settings';
|
||||
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
DatabaseCleanupResult,
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const settingsQueryKey = ['settings', 'options'] as const;
|
||||
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
@@ -152,12 +157,7 @@ type CleanupModalState = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SettingsTab =
|
||||
| 'personal'
|
||||
| 'operation'
|
||||
| 'database'
|
||||
| 'system'
|
||||
| 'other';
|
||||
type SettingsTab = 'personal' | 'operation' | 'database' | 'system' | 'other';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
@@ -249,10 +249,10 @@ export function SettingsPage() {
|
||||
const [otherFields, setOtherFields] = useState(defaultOtherFields);
|
||||
const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields);
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [wechatCode, setWeChatCode] = useState('');
|
||||
const [emailAddress, setEmailAddress] = useState('');
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
|
||||
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
||||
const [cleanupModalState, setCleanupModalState] =
|
||||
useState<CleanupModalState | null>(null);
|
||||
@@ -276,6 +276,12 @@ export function SettingsPage() {
|
||||
enabled: isRoot,
|
||||
});
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: authSourcesQueryKey,
|
||||
queryFn: getAuthSources,
|
||||
enabled: isRoot,
|
||||
});
|
||||
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['settings', 'bootstrap-token'],
|
||||
queryFn: getBootstrapToken,
|
||||
@@ -523,7 +529,7 @@ export function SettingsPage() {
|
||||
{
|
||||
key: 'system' as const,
|
||||
label: '系统设置',
|
||||
description: '登录注册、SMTP、OAuth、限流与风控开关。',
|
||||
description: '登录注册、SMTP、认证源、限流与风控开关。',
|
||||
},
|
||||
{
|
||||
key: 'database' as const,
|
||||
@@ -629,20 +635,10 @@ export function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindWeChat = () => {
|
||||
if (!wechatCode.trim()) {
|
||||
setFeedback({ tone: 'danger', message: '请输入微信验证码。' });
|
||||
return;
|
||||
}
|
||||
|
||||
void runBusyAction('wechat-bind', async () => {
|
||||
await bindWeChat(wechatCode.trim());
|
||||
setWeChatCode('');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }),
|
||||
refreshUser(),
|
||||
]);
|
||||
setFeedback({ tone: 'success', message: '微信账号已绑定。' });
|
||||
const handleBindAuthSource = (sourceName: string) => {
|
||||
void runBusyAction(`auth-source-bind-${sourceName}`, async () => {
|
||||
const result = await getOAuthAuthorizeUrl(sourceName);
|
||||
window.location.href = result.authorize_url;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -824,79 +820,36 @@ export function SettingsPage() {
|
||||
|
||||
<AppCard
|
||||
title="账号绑定"
|
||||
description="支持绑定 GitHub、微信和邮箱地址,用于统一个人身份入口。"
|
||||
description="支持绑定已启用的认证源和邮箱地址,用于统一个人身份入口。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
GitHub 账号
|
||||
第三方认证源
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
当前状态:
|
||||
{profile.github_id
|
||||
? `已绑定 ${profile.github_id}`
|
||||
: '未绑定'}
|
||||
登录状态下发起授权会直接绑定到当前账号。
|
||||
</p>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!publicStatus.github_oauth || !publicStatus.github_client_id
|
||||
}
|
||||
>
|
||||
{publicStatus.github_oauth
|
||||
? '绑定 GitHub'
|
||||
: '未启用 GitHub OAuth'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
微信账号
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
当前状态:
|
||||
{profile.wechat_id
|
||||
? `已绑定 ${profile.wechat_id}`
|
||||
: '未绑定'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(publicStatus.auth_sources ?? []).length > 0 ? (
|
||||
publicStatus.auth_sources.map((source) => (
|
||||
<PrimaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleBindAuthSource(source.name)}
|
||||
disabled={busyKey === `auth-source-bind-${source.name}`}
|
||||
>
|
||||
绑定 {source.display_name || source.name}
|
||||
</PrimaryButton>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前未启用认证源。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{publicStatus.wechat_login && publicStatus.wechat_qrcode ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={publicStatus.wechat_qrcode}
|
||||
alt="微信绑定二维码"
|
||||
className="h-40 w-40 rounded-2xl border border-[var(--border-default)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<ResourceField
|
||||
label="验证码"
|
||||
hint="扫码关注后输入“验证码”获取绑定码。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
placeholder="请输入微信验证码"
|
||||
/>
|
||||
</ResourceField>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleBindWeChat}
|
||||
disabled={
|
||||
!publicStatus.wechat_login || busyKey === 'wechat-bind'
|
||||
}
|
||||
>
|
||||
{busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -1279,8 +1232,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
<AppCard
|
||||
title="版本与构建信息">
|
||||
<AppCard title="版本与构建信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
@@ -1396,7 +1348,7 @@ export function SettingsPage() {
|
||||
</ResourceField>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
触发频率
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1404,7 +1356,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
默认执行时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1412,7 +1364,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
生效范围
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1490,8 +1442,16 @@ export function SettingsPage() {
|
||||
<AppCard
|
||||
title="登录与注册开关"
|
||||
description="切换后立即生效,无需重启服务。"
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setAuthSourceModalOpen(true)}
|
||||
>
|
||||
配置认证源
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ToggleField
|
||||
label="允许密码登录"
|
||||
description="关闭后将无法使用用户名密码登录。"
|
||||
@@ -1519,33 +1479,6 @@ export function SettingsPage() {
|
||||
}
|
||||
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用 GitHub OAuth"
|
||||
description="允许用户通过 GitHub 登录与注册。"
|
||||
checked={systemFields.GitHubOAuthEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('GitHubOAuthEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-GitHubOAuthEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用微信登录"
|
||||
description="允许用户通过微信入口登录与注册。"
|
||||
checked={systemFields.WeChatAuthEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('WeChatAuthEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-WeChatAuthEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用 Turnstile"
|
||||
description="开启后注册、邮箱验证码等流程需要先通过人机验证。"
|
||||
checked={systemFields.TurnstileCheckEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('TurnstileCheckEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-TurnstileCheckEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="允许新用户注册"
|
||||
description="关闭后将禁止所有新用户注册入口。"
|
||||
@@ -1556,6 +1489,9 @@ export function SettingsPage() {
|
||||
disabled={busyKey === 'toggle-RegisterEnabled'}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
||||
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||
@@ -1694,145 +1630,6 @@ export function SettingsPage() {
|
||||
</ResourceField>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="OAuth / WeChat / Turnstile"
|
||||
description="敏感密钥不会从后端回显,留空即保持原值。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('system-integrations', async () => {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
[
|
||||
'GitHubClientId',
|
||||
systemFields.GitHubClientId.trim(),
|
||||
],
|
||||
[
|
||||
'GitHubClientSecret',
|
||||
systemFields.GitHubClientSecret.trim(),
|
||||
],
|
||||
[
|
||||
'WeChatServerAddress',
|
||||
normalizeServerUrl(
|
||||
systemFields.WeChatServerAddress,
|
||||
),
|
||||
],
|
||||
[
|
||||
'WeChatServerToken',
|
||||
systemFields.WeChatServerToken.trim(),
|
||||
],
|
||||
[
|
||||
'WeChatAccountQRCodeImageURL',
|
||||
systemFields.WeChatAccountQRCodeImageURL.trim(),
|
||||
],
|
||||
[
|
||||
'TurnstileSiteKey',
|
||||
systemFields.TurnstileSiteKey.trim(),
|
||||
],
|
||||
[
|
||||
'TurnstileSecretKey',
|
||||
systemFields.TurnstileSecretKey.trim(),
|
||||
],
|
||||
],
|
||||
'第三方集成设置已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'system-integrations'}
|
||||
>
|
||||
{busyKey === 'system-integrations'
|
||||
? '保存中...'
|
||||
: '保存集成设置'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="GitHub Client ID">
|
||||
<ResourceInput
|
||||
value={systemFields.GitHubClientId}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
GitHubClientId: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="GitHub Client Secret">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.GitHubClientSecret}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
GitHubClientSecret: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="WeChat Server 地址">
|
||||
<ResourceInput
|
||||
value={systemFields.WeChatServerAddress}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatServerAddress: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="WeChat Server Token">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.WeChatServerToken}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatServerToken: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="公众号二维码链接">
|
||||
<ResourceInput
|
||||
value={systemFields.WeChatAccountQRCodeImageURL}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatAccountQRCodeImageURL: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="Turnstile Site Key">
|
||||
<ResourceInput
|
||||
value={systemFields.TurnstileSiteKey}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
TurnstileSiteKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="Turnstile Secret Key">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.TurnstileSecretKey}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
TurnstileSecretKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppCard
|
||||
title="请求限流设置"
|
||||
@@ -2247,6 +2044,20 @@ export function SettingsPage() {
|
||||
|
||||
{renderTabContent()}
|
||||
|
||||
<AuthSourceModal
|
||||
isOpen={authSourceModalOpen}
|
||||
sources={authSourcesQuery.data ?? []}
|
||||
isLoading={authSourcesQuery.isLoading}
|
||||
error={authSourcesQuery.error}
|
||||
onClose={() => setAuthSourceModalOpen(false)}
|
||||
onChanged={async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: authSourcesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-status'] }),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={cleanupModalState !== null}
|
||||
title={`清理${cleanupModalState?.label ?? ''}`}
|
||||
|
||||
@@ -9,6 +9,30 @@ export interface OptionBatchPayload {
|
||||
options: OptionItem[];
|
||||
}
|
||||
|
||||
export type AuthSourceType = 'github' | 'oidc';
|
||||
|
||||
export interface AuthSource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: AuthSourceType;
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_secret_configured?: boolean;
|
||||
openid_discovery_url: string;
|
||||
scopes: string;
|
||||
icon_url: string;
|
||||
}
|
||||
|
||||
export type AuthSourcePayload = Omit<
|
||||
AuthSource,
|
||||
'id' | 'client_secret_configured'
|
||||
> & {
|
||||
id?: number;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export interface BootstrapTokenPayload {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
export interface PublicStatus {
|
||||
version: string;
|
||||
start_time: number;
|
||||
email_verification: boolean;
|
||||
github_oauth: boolean;
|
||||
github_client_id: string;
|
||||
system_name: string;
|
||||
home_page_link: string;
|
||||
footer_html: string;
|
||||
wechat_qrcode: string;
|
||||
export interface PublicAuthSource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'github' | 'oidc';
|
||||
display_name: string;
|
||||
authorize_url: string;
|
||||
icon_url?: string;
|
||||
}
|
||||
|
||||
export interface PublicStatus {
|
||||
version: string;
|
||||
start_time: number;
|
||||
email_verification: boolean;
|
||||
github_oauth: boolean;
|
||||
github_client_id: string;
|
||||
system_name: string;
|
||||
home_page_link: string;
|
||||
footer_html: string;
|
||||
wechat_qrcode: string;
|
||||
wechat_login: boolean;
|
||||
server_address: string;
|
||||
turnstile_check: boolean;
|
||||
turnstile_site_key: string;
|
||||
register_enabled: boolean;
|
||||
password_register_enabled: boolean;
|
||||
auth_sources: PublicAuthSource[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user