diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py index d981f9bc..fa88a6b7 100644 --- a/backend/app/auth/directory.py +++ b/backend/app/auth/directory.py @@ -119,14 +119,14 @@ POOL_SLOT = "slot:" def pool_problem(value: Any) -> str | None: """x-principal 的写法对不对:只能是 {pool: app | group:<组> | slot:<岗位>}。没问题为 None。""" if not isinstance(value, dict) or set(value) != {"pool"} or not isinstance(value.get("pool"), str): - return "的候选范围只能写 {\"pool\": \"app\"}、{\"pool\": \"group:组名\"} 或 {\"pool\": \"slot:岗位\"}" + return "的候选范围只能写成 {\"pool\": \"app\"}、{\"pool\": \"group:<组名>\"} 或 {\"pool\": \"slot:<岗位>\"}" pool = value["pool"].strip() if pool == POOL_APP: return None for prefix in (POOL_GROUP, POOL_SLOT): if pool.startswith(prefix) and pool[len(prefix):].strip(): return None - return "的候选范围只能是 app(能用这个应用的人)、group:组名 或 slot:岗位" + return "的候选范围只能是 app(能用这个应用的人)、group:<组名> 或 slot:<岗位>" def pool_of(field: Any) -> str: diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 51990394..b20eb648 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -2,7 +2,7 @@ * 身份的接口(/api/auth、/api/users、/api/groups、/api/audit),挂在 api.auth / api.identity 上。形状见 typesApp.ts。 * 开登录时:没登录的请求回 401(前端跳 /login),角色不够回 403。关登录时 /me 恒为 mode='off'。 */ -import type { AuditRow, AuthMode, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp' +import type { AuditRow, AuthMode, CandidateSource, Candidates, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' export const auth = { @@ -29,6 +29,9 @@ export const identity = { groups: () => get<{ items: GroupRow[] }>('/groups'), /** 选人用的名录:搭建者及以上只读(/users、/groups 只给管理员) */ directory: () => get('/directory'), + /** 使用者选人的候选:按用途给的池,只有名字(任何登录的人都能调;用不了那个应用的 403)。q 按开头筛 */ + candidates: (source: CandidateSource, q?: string, limit = 20) => + get(`/directory/candidates${qs({ ...source, q: q || undefined, limit })}`), createGroup: (body: { name: string; label?: string }) => post('/groups', body), setMembers: (id: string, members: string[]) => put(`/groups/${encodeURIComponent(id)}/members`, { members }), deleteGroup: (id: string) => del(`/groups/${encodeURIComponent(id)}`), diff --git a/frontend/src/components/PrincipalPicker.tsx b/frontend/src/components/PrincipalPicker.tsx index 229061d9..c1b68c6e 100644 --- a/frontend/src/components/PrincipalPicker.tsx +++ b/frontend/src/components/PrincipalPicker.tsx @@ -5,15 +5,18 @@ * 名录里没有的、停用了的不让添加,说清是哪一个(账号密码登录时人和组都在本系统里建,名录里没有就是填错了);单点登录的 * 人和组第一次登录后才进名录,名录里没有的照实提醒,点「仍然添加」才加上。已选的里头名录里没有的、停用了的在标签上 * 标出来,不静默放着。 - * 使用者读不到名录:照填,服务端按名字认。关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。 + * 使用者读不到名录(只读与最小可见):给了 source 的(表单里的选人控件、个人应用提交审核)边输入边按开头取候选 + * (GET /api/directory/candidates,05-已知遗留 (b)),只有这件事能选的人、只有名字;加的时候按同一个池问一次服务端,不在池里的 + * 当场说清。提交时服务端照同一个池核对(不在池里 422),这里只是提前说。没给 source 的照填,服务端按名字认。 + * 关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。 */ -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { Plus, Users, UserRound, X } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { PRINCIPAL_TEXT } from '../lib/termsApp' import { useAtLeast, useAuth, useLoginOn } from '../store/auth' -import type { Directory } from '../typesApp' +import type { CandidateSource, Candidates, Directory } from '../typesApp' import { isComposing } from './ui' export interface Principal { @@ -29,8 +32,16 @@ export interface PrincipalPickerProps { /** 只能选一个 */ single?: boolean label?: string + /** + * 使用者取候选的池(服务端按用途算):表单里的选人控件(入口、待办)、个人应用提交审核。搭建者及以上照旧用整份名录, + * 不看它 + */ + source?: CandidateSource } +/** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */ +const ASK_DELAY_MS = 250 + /** 名录只取一次,同一页上几个选择器共用;没取到(断线)下次打开再取 */ let directory: Promise | null = null function loadDirectory(): Promise { @@ -46,15 +57,21 @@ function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled return !user ? 'missing' : user.disabled ? 'disabled' : null } -export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label }: PrincipalPickerProps) { +export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) { const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '') const loginOn = useLoginOn() // 名录给搭建者及以上(使用者读不到,不去取:取了也是 403) const listable = useAtLeast('builder') && loginOn + // 使用者:有 source 就按池取候选 + const asking = !listable && loginOn && !!source + const sourceKey = source ? JSON.stringify(source) : '' const oidc = useAuth((s) => s.me?.mode === 'oidc') const [kind, setKind] = useState<'user' | 'group'>('user') const [text, setText] = useState('') const [known, setKnown] = useState(null) + const [pool, setPool] = useState(null) + /** 取到过的候选:用户名 → 显示名(标签上写显示名,悬停给用户名) */ + const shown = useRef(new Map()) /** 刚才要加、名录里对不上的那一项:soft 是单点登录下名录里还没有(可以「仍然添加」),其余不让加 */ const [refused, setRefused] = useState<{ item: Principal; message: string; soft: boolean } | null>(null) useEffect(() => { @@ -63,12 +80,38 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single void loadDirectory().then((d) => { if (alive) setKnown(d) }) return () => { alive = false } }, [listable]) + useEffect(() => { + if (!asking || !source) return + let alive = true + const timer = window.setTimeout(() => { + api.identity.candidates(source, text.trim()) + .then((c) => { + if (!alive) return + for (const u of c.users) shown.current.set(u.username, u.display_name) + setPool(c) + }) + .catch(() => { if (alive) setPool(null) }) + }, ASK_DELAY_MS) + return () => { alive = false; window.clearTimeout(timer) } + // sourceKey 代替 source:调用方每次渲染都给一个新对象 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [asking, sourceKey, text]) const has = (p: Principal) => value.some((x) => x.kind === p.kind && x.name === p.name) - const add = (p: Principal, anyway = false) => { + const add = async (p: Principal, anyway = false) => { const item = { ...p, name: p.name.trim() } if (!item.name) return if (has(item)) { setText(''); setRefused(null); return } + if (asking && source && item.kind !== 'everyone') { + // 按同一个池问一次服务端:不在池里的当场说清(提交时服务端照样拒)。问不到(断线)就先收下,提交时由服务端判 + const found = await api.identity.candidates(source, item.name, 50).catch(() => null) + const listed = found && (item.kind === 'group' ? found.groups.some((g) => g.name === item.name) + : found.users.some((u) => u.username === item.name)) + if (found && !listed) { + setRefused({ item, soft: false, message: PRINCIPAL_TEXT.notCandidate(item.name) }) + return + } + } const problem = problemOf(item, known) const soft = problem === 'missing' && oidc if (problem && !(soft && anyway)) { @@ -87,9 +130,16 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single const remove = (p: Principal) => onChange(value.filter((x) => !(x.kind === p.kind && x.name === p.name))) const listId = `${uid}-list` const errorId = `${uid}-error` - const options = !known ? [] : kind === 'user' - ? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name })) - : known.groups.map((g) => ({ value: g.name, label: g.label })) + const options = asking + ? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name })) + : pool.groups.map((g) => ({ value: g.name, label: g.label }))) + : !known ? [] : kind === 'user' + ? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name })) + : known.groups.map((g) => ({ value: g.name, label: g.label })) + /** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */ + const poolHint = asking && pool + ? (pool.needs_query && !text.trim() ? PRINCIPAL_TEXT.typeToSearch : pool.truncated ? PRINCIPAL_TEXT.truncated : null) + : null return (
@@ -102,7 +152,9 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single data-principal-problem={problem ?? undefined} style={problem ? { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' } : undefined}> {p.kind === 'group' ? : p.kind === 'user' ? : null} - {p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name} + + {p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : shown.current.get(p.name) ?? p.name} + ({PRINCIPAL_TEXT[p.kind]}) {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}} {allowEveryone && ( )} @@ -151,12 +203,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single style={{ color: refused.soft ? 'var(--st-waiting)' : 'var(--err)' }} data-principal-refused={refused.soft ? 'soft' : 'hard'}> {refused.message} {refused.soft && ( - )}
)} + {poolHint &&
{poolHint}
} {!loginOn &&
{PRINCIPAL_TEXT.manualHint}
} ) diff --git a/frontend/src/components/SchemaForm.tsx b/frontend/src/components/SchemaForm.tsx index ffa52aaa..b2b0780f 100644 --- a/frontend/src/components/SchemaForm.tsx +++ b/frontend/src/components/SchemaForm.tsx @@ -12,7 +12,7 @@ import { useEffect, useId, useState } from 'react' import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react' import clsx from 'clsx' -import type { FormError, FormField, FormSchema } from '../typesApp' +import type { CandidateSource, FormError, FormField, FormSchema } from '../typesApp' import { FORM_TEXT } from '../lib/termsApp' import { fieldOfPath, fieldOrder, fileLimitsOf, localToRfc3339, optionsOf, rfc3339ToLocal, widgetOf, @@ -88,6 +88,16 @@ export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit ) } +/** + * 选人控件的候选从哪个池取:表单用在入口(app_id + entry)、待办(task_id)上时,按这个字段问服务端(05-已知遗留 (b)); + * 蓝图试运行之类没有应用的地方不给(搭建者用整份名录) + */ +function principalSource(context: Record | undefined, field: string): CandidateSource | undefined { + if (context?.task_id) return { purpose: 'form', task_id: context.task_id, field } + if (context?.app_id && context.entry) return { purpose: 'form', app_id: context.app_id, entry: context.entry, field } + return undefined +} + function FieldRow({ id, name, field, required, value, error, readOnly, onChange, fileContext }: { id: string; name: string; field: FormField; required: boolean; value: unknown; error?: FormError readOnly: boolean; onChange: (v: unknown) => void; fileContext?: Record @@ -143,7 +153,7 @@ function FieldRow({ id, name, field, required, value, error, readOnly, onChange,
{label}
{readOnly ?
{typeof value === 'string' && value ? value : '—'}
- : onChange(list[0]?.name || undefined)} />} {foot} diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 13c5b33e..3c24e994 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -368,6 +368,10 @@ export const PRINCIPAL_TEXT = { /** 已选的那一项不在名录里、或已停用:标在标签上 */ missingTag: '名录里没有', disabledTag: '已停用', + /** 使用者选人:候选按这件事能选的人给(服务端 auth/directory 的候选池,05-已知遗留 (b)) */ + notCandidate: (name: string) => `「${name}」不在可选的人里,请从候选中选择`, + typeToSearch: '能选的人较多:输入名字的开头查找', + truncated: '只列出了一部分:多输入几个字缩小范围', } /** 身份:登录、账户菜单、角色(§4.4 AUTH_TEXT) */ @@ -445,6 +449,15 @@ export const SIGNER_TEXT = { record: '记入留痕', } +/** + * 个人渠道(设置 → 通知,范围是「仅自己」)里实例结束的两个事件:只推自己发起、而且是登录身份发起的实例(服务端 + * notify/service.notify_run,04-对外集成与触达 B1)。以前叫「实例完成 / 失败」,勾了却永远收不到 + */ +export const NOTIFY_PERSONAL_EVENT_LABEL: Record = { + 'run.finished': '我发起的实例完成', + 'run.failed': '我发起的实例失败', +} + /** 发布弹窗按角色收起的档位(Q8:受管级发布只给管理员,服务端 api/workflows 同样拒) */ export const PUBLISH_ROLE_TEXT = { governedAdminOnly: '需要管理员:以受管级发布只给管理员,请联系管理员发布', diff --git a/frontend/src/pages/apps/personal.tsx b/frontend/src/pages/apps/personal.tsx index c5298c15..2e10036d 100644 --- a/frontend/src/pages/apps/personal.tsx +++ b/frontend/src/pages/apps/personal.tsx @@ -171,7 +171,7 @@ function ReviewDialog({ appId, onClose, onSent }: {

{T.reviewIntro}

{T.reviewTo}
- +

{T.reviewToHint}

diff --git a/frontend/src/pages/settings/NotifyTab.tsx b/frontend/src/pages/settings/NotifyTab.tsx index d30ffda5..0be8ce32 100644 --- a/frontend/src/pages/settings/NotifyTab.tsx +++ b/frontend/src/pages/settings/NotifyTab.tsx @@ -6,6 +6,7 @@ import { } from '../../components/ui' import { formatDateTime } from '../../lib/format' import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms' +import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' import { useAtLeast } from '../../store/auth' import type { NotifyChannelRow } from '../../typesApp' @@ -18,6 +19,9 @@ import type { NotifyChannelRow } from '../../typesApp' const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const const EVENTS = Object.keys(T.eventLabel) +/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */ +const eventLabel = (event: string, scope: string): string => + (scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event export function NotifyTab() { const builder = useAtLeast('builder') @@ -86,7 +90,7 @@ export function NotifyTab() {
{row.url_host} - {row.events.length > 0 && ` · ${row.events.map((e) => T.eventLabel[e] ?? e).join('、')}`} + {row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}