mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* fix: portal the confirmation modal so drawers cannot cover it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: log a folder acl grant under the permission it granted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a table's actions column at its right edge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: edit a folder in a drawer that saves once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: call the people on a folder or item members Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: edit a folder against the workspace the drawer targets Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: drop the now-unused sticky actions column Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * docs: correct the script editor drawer's modal placement note Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: pin the actions column without losing the row's hover tint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: show the pinned column's seam only while the table overflows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: draw the pinned column's seam as a shadow so it does not scroll away Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: fade the pinned column's tint in step with its row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: address review nits on the folder editor and pinned cell Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep the folder draft across a user-store refresh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: extract and test the folder draft's dirty check and permission diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: stop the folder editor showing state the server refused Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a folder draft that no request ever reached the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep unapplied folder edits dirty when a save partially fails * fix: block folder form edits while a save is in flight * fix: commit a typed folder label before save snapshots the draft * fix: count a typed folder label as an unsaved change * fix: keep escape in the label input from closing what encloses it * fix: capitalize folder table headers and drop a dead portal target * refactor: make the confirmation modal portal opt-in per call site * docs: name the stacking context that actually traps the discard dialog * fix: report a half-landed member removal so the baseline reconciles * feat: edit a group in a drawer that saves once * fix: freeze the group name once the group exists * fix: revoke the caller's own group acl last so the rest of the save is authorized * docs: state the group call-ordering invariant once * fix: report a failing post-save reload instead of dropping the rejection * fix: hand the folder list reload back so a failure is reported * fix: treat a rejected group create as inconclusive and catch a throwing onSaved * revert: stop inferring a group was created from its name being taken * fix: say when a failed group create may have saved the group anyway * fix: key the may-have-been-created hint on the name conflict, not the status * fix: skip the may-have-been-created hint when the group is known to exist * feat: open a folder's group member from its row * fix: stop showing the caller as an admin when the read failed * fix: give up the caller's own folder admin last, and label a create as one * fix: drop a folder member's acl before its owner entry * fix: remove a folder owner before their acl, and correct the rls rationale * docs: say the refusal is on the caller's last admin handle * fix: defer only the folder rows the caller is an admin through * docs: describe callerOwners as what the caller passes in * docs: drop the call-site restatement of the diff's own invariant * docs: record manager as a legacy group role * fix: treat a sent request as possibly committed when reconciling * fix: reconcile on any failed edit, and compare members as a set * fix: keep write access when only the reconcile read fails --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
import { deepEqual } from 'fast-equals'
|
|
|
|
/** What a member may hold on a group. `member` is the `usr_to_group` row server-side and
|
|
* `manager` is the `true` entry in `extra_perms`; `admin` is both at once. */
|
|
export type GroupRole = 'member' | 'manager' | 'admin'
|
|
|
|
export type GroupMember = { member_name: string; role: GroupRole }
|
|
|
|
/** Everything the group editor can change, held as one value so the whole edit is one
|
|
* comparison against the loaded group and one Save. */
|
|
export type GroupDraft = {
|
|
summary: string
|
|
members: GroupMember[]
|
|
}
|
|
|
|
/** Whether the draft still matches the group it was loaded from. Every field of `GroupDraft`
|
|
* participates, so a field added to the type is covered by construction — which is what the
|
|
* discard guard depends on: an edit this misses is an edit the drawer throws away without
|
|
* asking. No baseline means nothing has loaded yet, so nothing to lose. */
|
|
export function isGroupDraftDirty(draft: GroupDraft, baseline: GroupDraft | undefined): boolean {
|
|
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
|
|
}
|
|
|
|
/** Members are a set, but a reload rebuilds them in the server's order while the draft keeps
|
|
* the order they were added in. Compared as-is, a change that has already been applied still
|
|
* reads as dirty. */
|
|
function sortedMembers(value: GroupDraft): GroupDraft {
|
|
return {
|
|
...value,
|
|
members: [...value.members].sort((a, b) => a.member_name.localeCompare(b.member_name))
|
|
}
|
|
}
|
|
|
|
/** One backend call a group's members need. Kept as data so the mapping from role
|
|
* transitions to endpoints can be read — and tested — without a server. */
|
|
export type GroupMemberCall =
|
|
/** `addUserToGroup` / `removeUserToGroup`: the `usr_to_group` row. */
|
|
| { kind: 'addUser'; username: string }
|
|
| { kind: 'removeUser'; username: string }
|
|
/** `acls/add` / `acls/remove` on kind `group_`: the write entry that lets someone
|
|
* manage the group. */
|
|
| { kind: 'setAcl'; username: string }
|
|
| { kind: 'removeAcl'; username: string }
|
|
|
|
/** The two independent things a role is made of: belonging to the group, and holding the
|
|
* write entry that lets you manage it. Every role is one combination of the two, which is
|
|
* why a transition needs at most one call per flag. */
|
|
function flagsOf(role: GroupRole | undefined): { belongs: boolean; manages: boolean } {
|
|
return {
|
|
belongs: role === 'member' || role === 'admin',
|
|
manages: role === 'manager' || role === 'admin'
|
|
}
|
|
}
|
|
|
|
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none, and
|
|
* a member dropped from `next` is treated as holding neither flag — which is what removing
|
|
* one means.
|
|
*
|
|
* `require_is_owner` authorizes each of these against `extra_perms['u/<caller>']`, so the
|
|
* caller's own revocation goes last: in row order it lands first and the rest 403s. */
|
|
export function groupMemberDiff(
|
|
prev: GroupMember[],
|
|
next: GroupMember[],
|
|
caller?: string
|
|
): GroupMemberCall[] {
|
|
const previousRole = new Map(prev.map((p) => [p.member_name, p.role]))
|
|
const calls: GroupMemberCall[] = []
|
|
|
|
const transition = (
|
|
username: string,
|
|
before: GroupRole | undefined,
|
|
after: GroupRole | undefined
|
|
) => {
|
|
const from = flagsOf(before)
|
|
const to = flagsOf(after)
|
|
if (to.belongs !== from.belongs) {
|
|
calls.push({ kind: to.belongs ? 'addUser' : 'removeUser', username })
|
|
}
|
|
if (to.manages !== from.manages) {
|
|
calls.push({ kind: to.manages ? 'setAcl' : 'removeAcl', username })
|
|
}
|
|
}
|
|
|
|
for (const member of next) {
|
|
const before = previousRole.get(member.member_name)
|
|
if (before === member.role) continue
|
|
transition(member.member_name, before, member.role)
|
|
}
|
|
|
|
const kept = new Set(next.map((n) => n.member_name))
|
|
for (const member of prev) {
|
|
if (kept.has(member.member_name)) continue
|
|
transition(member.member_name, member.role, undefined)
|
|
}
|
|
|
|
const revokesCaller = (call: GroupMemberCall) =>
|
|
call.kind === 'removeAcl' && call.username === caller
|
|
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
|
|
}
|