权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝): - 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免 - 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*) - 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝 后端: - 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD - RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry - /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限 - 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库 前端: - useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫 - 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选) - 登录页记住密码;GUIDE/API 文档补权限章节
120 lines
5.1 KiB
TypeScript
120 lines
5.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Rocket, Loader2, KeyRound, User } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { apiClient, auth } from '@/lib/api';
|
|
|
|
const REMEMBER_KEY = 'flowstack-remember';
|
|
|
|
// 记住密码:明文存 localStorage 仅适合内网工具场景;不勾选则彻底不落盘
|
|
function readRemember(): { username: string; password: string } | null {
|
|
try {
|
|
const raw = localStorage.getItem(REMEMBER_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed?.username && parsed?.password) return parsed;
|
|
} catch {
|
|
// 脏数据忽略
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
|
const remembered = readRemember();
|
|
const [username, setUsername] = useState(remembered?.username ?? '');
|
|
const [password, setPassword] = useState(remembered?.password ?? '');
|
|
const [remember, setRemember] = useState(!!remembered);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const submit = async (e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
if (!username.trim() || !password) {
|
|
toast.error('请输入用户名和密码');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiClient.login(username.trim(), password);
|
|
auth.setToken(res.token);
|
|
if (remember) {
|
|
localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username: username.trim(), password }));
|
|
} else {
|
|
localStorage.removeItem(REMEMBER_KEY);
|
|
}
|
|
toast.success(`欢迎,${res.nickname || res.username}`);
|
|
onSuccess();
|
|
} catch (err) {
|
|
toast.error('登录失败', { description: (err as Error).message });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
|
|
|
return (
|
|
<div className="h-screen w-screen flex items-center justify-center" style={{ background: 'var(--canvas-bg)' }}>
|
|
<div className="w-[380px] rounded-xl border shadow-lg p-8" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="flex flex-col items-center mb-6">
|
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
|
<Rocket className="w-6 h-6 text-white" />
|
|
</div>
|
|
<h1 className="text-[18px] font-bold" style={{ color: 'var(--text-primary)' }}>FlowStack</h1>
|
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>发布系统 · 请登录</p>
|
|
</div>
|
|
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
|
<div className="relative">
|
|
<User className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
|
<input
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoFocus
|
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
|
style={inputStyle}
|
|
placeholder="admin"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</label>
|
|
<div className="relative">
|
|
<KeyRound className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
|
style={inputStyle}
|
|
placeholder="初始密码见 data/initial-admin-password.txt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 cursor-pointer select-none" title="在本机记住账号与密码,公共电脑请勿勾选">
|
|
<input
|
|
type="checkbox"
|
|
checked={remember}
|
|
onChange={(e) => setRemember(e.target.checked)}
|
|
className="w-3.5 h-3.5 accent-[var(--accent-blue)]"
|
|
/>
|
|
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>记住密码</span>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-2.5 text-[13px] font-medium rounded-md text-white disabled:opacity-60 flex items-center justify-center gap-2"
|
|
style={{ background: 'var(--accent-blue)' }}
|
|
>
|
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
登 录
|
|
</button>
|
|
</form>
|
|
<p className="text-[11px] text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
|
|
支持本地账号与 LDAP 账号(启用后自动识别)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|