mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 06:00:46 +08:00
189 lines
6.2 KiB
JavaScript
189 lines
6.2 KiB
JavaScript
/**
|
|
* Job ownership helpers for per-user cron isolation (aligns with uds-auth roles).
|
|
*
|
|
* Two modes:
|
|
* - standalone (no uds-auth): identity empNo is LOCAL_EMP_NO → everyone sees all jobs
|
|
* - multi-user (uds-auth present): filter/claim by real empNo
|
|
*/
|
|
|
|
export const UNASSIGNED_OWNER = '__unassigned__'
|
|
export const LOCAL_EMP_NO = '__local__'
|
|
|
|
export function normalizeOwnerEmpNo(value) {
|
|
const empNo = String(value || '').trim()
|
|
return empNo || UNASSIGNED_OWNER
|
|
}
|
|
|
|
/** True when uds-auth supplied a real user identity (not standalone local). */
|
|
export function isMultiUserIdentity(identity) {
|
|
const empNo = String(identity?.empNo || '').trim()
|
|
return !!empNo && empNo !== LOCAL_EMP_NO
|
|
}
|
|
|
|
export function localIdentity(overrides = {}) {
|
|
return {
|
|
empNo: LOCAL_EMP_NO,
|
|
role: 'local',
|
|
displayName: 'local',
|
|
permissions: { canViewAllSessions: true },
|
|
workspacePath: null,
|
|
mode: 'local',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function canViewAllJobs(identity) {
|
|
if (!isMultiUserIdentity(identity)) return true
|
|
return !!identity?.permissions?.canViewAllSessions
|
|
}
|
|
|
|
export function jobVisibleToIdentity(job, identity) {
|
|
if (!identity?.empNo) return false
|
|
// Standalone / local: no empNo isolation.
|
|
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return true
|
|
const owner = normalizeOwnerEmpNo(job?.ownerEmpNo)
|
|
// Unclaimed jobs are admin-only in multi-user mode.
|
|
if (owner === UNASSIGNED_OWNER) return false
|
|
return owner === String(identity.empNo)
|
|
}
|
|
|
|
export function assertCanAccessJob(job, identity) {
|
|
if (!job) {
|
|
const error = new Error('job not found')
|
|
error.code = 'NOT_FOUND'
|
|
throw error
|
|
}
|
|
if (!identity?.empNo) {
|
|
const error = new Error('login_required')
|
|
error.code = 'LOGIN_REQUIRED'
|
|
throw error
|
|
}
|
|
if (!jobVisibleToIdentity(job, identity)) {
|
|
const error = new Error('job not found')
|
|
error.code = 'NOT_FOUND'
|
|
throw error
|
|
}
|
|
return job
|
|
}
|
|
|
|
export function filterJobsForIdentity(jobs, identity) {
|
|
const list = Array.isArray(jobs) ? jobs : []
|
|
if (!identity?.empNo) return []
|
|
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return [...list]
|
|
return list.filter((job) => jobVisibleToIdentity(job, identity))
|
|
}
|
|
|
|
export function filterRunsForJobs(runs, jobs) {
|
|
const allowed = new Set((jobs || []).map((job) => job.id))
|
|
return (Array.isArray(runs) ? runs : []).filter((run) => allowed.has(run.jobId))
|
|
}
|
|
|
|
/**
|
|
* Infer owner for legacy jobs missing ownerEmpNo.
|
|
* @param {object} job
|
|
* @param {{ getSessionOwner?: (sessionId: string) => string|null }} [deps]
|
|
*/
|
|
export function inferOwnerEmpNo(job, deps = {}) {
|
|
if (job?.ownerEmpNo) return normalizeOwnerEmpNo(job.ownerEmpNo)
|
|
const origin = job?.origin
|
|
if (origin?.kind === 'im') return UNASSIGNED_OWNER
|
|
const sessionId = origin?.kind === 'web' && typeof origin.sessionId === 'string'
|
|
? origin.sessionId.trim()
|
|
: ''
|
|
if (sessionId && typeof deps.getSessionOwner === 'function') {
|
|
const owner = deps.getSessionOwner(sessionId)
|
|
if (owner) return normalizeOwnerEmpNo(owner)
|
|
}
|
|
return UNASSIGNED_OWNER
|
|
}
|
|
|
|
/**
|
|
* Migrate jobs in-place; returns { state, changed }.
|
|
*/
|
|
export function migrateJobOwners(state, deps = {}) {
|
|
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
|
|
let changed = false
|
|
const nextJobs = jobs.map((job) => {
|
|
if (!job || typeof job !== 'object') return job
|
|
if (job.ownerEmpNo) {
|
|
const normalized = normalizeOwnerEmpNo(job.ownerEmpNo)
|
|
if (normalized === job.ownerEmpNo && job.ownerDisplayName !== undefined) return job
|
|
changed = true
|
|
return {
|
|
...job,
|
|
ownerEmpNo: normalized,
|
|
ownerDisplayName: job.ownerDisplayName || '',
|
|
}
|
|
}
|
|
changed = true
|
|
return {
|
|
...job,
|
|
ownerEmpNo: inferOwnerEmpNo(job, deps),
|
|
ownerDisplayName: job.ownerDisplayName || '',
|
|
}
|
|
})
|
|
if (!changed) return { state, changed: false }
|
|
return { state: { ...state, jobs: nextJobs }, changed: true }
|
|
}
|
|
|
|
export function viewerPayload(identity) {
|
|
if (!identity?.empNo) return null
|
|
const multi = isMultiUserIdentity(identity)
|
|
return {
|
|
empNo: identity.empNo,
|
|
role: identity.role || (multi ? 'user' : 'local'),
|
|
displayName: identity.displayName || identity.empNo,
|
|
canViewAll: canViewAllJobs(identity),
|
|
workspacePath: identity.workspacePath || null,
|
|
mode: multi ? 'multi' : 'local',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default cwd when creating a job in the sidebar.
|
|
* Prefer the viewer's provisioned workspace over whatever project the session
|
|
* happens to have open (admins often sit in an unrelated clone).
|
|
*/
|
|
export function preferredNewJobCwd({ viewer = null, sessionCwd = '' } = {}) {
|
|
const provisioned = String(viewer?.workspacePath || '').trim()
|
|
if (viewer?.mode === 'multi' && provisioned) return provisioned
|
|
if (provisioned) return provisioned
|
|
return String(sessionCwd || '').trim()
|
|
}
|
|
|
|
/**
|
|
* Claim unassigned jobs that clearly belong to the viewer (origin session / cwd).
|
|
* No-op in standalone / canViewAll. Returns { state, changed }.
|
|
*/
|
|
export function claimUnassignedForViewer(state, identity, deps = {}) {
|
|
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) {
|
|
return { state, changed: false }
|
|
}
|
|
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
|
|
let changed = false
|
|
const nextJobs = jobs.map((job) => {
|
|
if (!job || normalizeOwnerEmpNo(job.ownerEmpNo) !== UNASSIGNED_OWNER) return job
|
|
const sessionId = job.origin?.kind === 'web' && typeof job.origin.sessionId === 'string'
|
|
? job.origin.sessionId.trim()
|
|
: ''
|
|
let mine = false
|
|
// Only claim when evidence ties the job to this viewer.
|
|
if (sessionId && typeof deps.getSessionOwner === 'function') {
|
|
mine = deps.getSessionOwner(sessionId) === identity.empNo
|
|
}
|
|
if (!mine && job.cwd) {
|
|
const norm = String(job.cwd).replace(/\\/g, '/')
|
|
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
|
|
mine = !!(match && decodeURIComponent(match[1]) === identity.empNo)
|
|
}
|
|
if (!mine) return job
|
|
changed = true
|
|
return {
|
|
...job,
|
|
ownerEmpNo: identity.empNo,
|
|
ownerDisplayName: identity.displayName || identity.empNo,
|
|
}
|
|
})
|
|
if (!changed) return { state, changed: false }
|
|
return { state: { ...state, jobs: nextJobs }, changed: true }
|
|
}
|