mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-22 08:02:40 +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>
97 lines
4.4 KiB
TypeScript
97 lines
4.4 KiB
TypeScript
import { deepEqual } from 'fast-equals'
|
|
import type { FolderDefaultPermissionedAs } from '$lib/gen'
|
|
|
|
/** What a member may hold on a folder. `admin` is the `owners` array server-side; `writer`
|
|
* and `viewer` are the `true`/`false` entries of `extra_perms`. */
|
|
export type FolderRole = 'viewer' | 'writer' | 'admin'
|
|
|
|
export type FolderMember = { owner_name: string; role: FolderRole }
|
|
|
|
/** Everything the folder editor can change, held as one value so the whole edit is one
|
|
* comparison against the loaded folder and one Save. */
|
|
export type FolderDraft = {
|
|
summary: string
|
|
labels: string[]
|
|
defaultPermissionedAs: FolderDefaultPermissionedAs
|
|
perms: FolderMember[]
|
|
}
|
|
|
|
/** Whether the draft still matches the folder it was loaded from. Every field of
|
|
* `FolderDraft` 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 isFolderDraftDirty(draft: FolderDraft, baseline: FolderDraft | undefined): boolean {
|
|
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
|
|
}
|
|
|
|
/** Members are a set, but a reload rebuilds them in the server's `extra_perms` key 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. Labels and rules keep their order, which is meaningful. */
|
|
function sortedMembers(value: FolderDraft): FolderDraft {
|
|
return {
|
|
...value,
|
|
perms: [...value.perms].sort((a, b) => a.owner_name.localeCompare(b.owner_name))
|
|
}
|
|
}
|
|
|
|
/** One backend call the folder's members need. Kept as data so the mapping from role
|
|
* transitions to endpoints can be read — and tested — without a server. */
|
|
export type FolderPermissionCall =
|
|
/** `addowner`: appends to `owners` and sets `extra_perms[owner] = true`. */
|
|
| { kind: 'grantAdmin'; owner: string }
|
|
/** `removeowner` with a write flag: takes the member out of `owners` and sets their
|
|
* level. The only way down from admin. */
|
|
| { kind: 'demoteAdmin'; owner: string; write: boolean }
|
|
/** `acls/add`: sets `extra_perms[owner]`, for a member who is not an admin. */
|
|
| { kind: 'setAcl'; owner: string; write: boolean }
|
|
/** Both removals. `removeowner` without a write only drops the member from `owners`,
|
|
* leaving their `extra_perms` entry — alone it demotes an admin rather than removing
|
|
* them, so the ACL delete is not optional. */
|
|
| { kind: 'remove'; owner: string }
|
|
|
|
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none.
|
|
*
|
|
* `callerOwners` is the caller's own `u/name` plus every group they belong to. Giving up the
|
|
* last of those that is in `owners` goes last: the write policy checks the row the update
|
|
* would produce, so that call is refused for anyone but a workspace admin, and sent early it
|
|
* takes the rest of the save with it. */
|
|
export function folderPermissionDiff(
|
|
prev: FolderMember[],
|
|
next: FolderMember[],
|
|
callerOwners?: string[]
|
|
): FolderPermissionCall[] {
|
|
const previousRole = new Map(prev.map((p) => [p.owner_name, p.role]))
|
|
const calls: FolderPermissionCall[] = []
|
|
|
|
for (const member of next) {
|
|
const before = previousRole.get(member.owner_name)
|
|
if (before === member.role) continue
|
|
if (member.role === 'admin') {
|
|
calls.push({ kind: 'grantAdmin', owner: member.owner_name })
|
|
} else if (before === 'admin') {
|
|
calls.push({
|
|
kind: 'demoteAdmin',
|
|
owner: member.owner_name,
|
|
write: member.role === 'writer'
|
|
})
|
|
} else {
|
|
calls.push({ kind: 'setAcl', owner: member.owner_name, write: member.role === 'writer' })
|
|
}
|
|
}
|
|
|
|
const kept = new Set(next.map((n) => n.owner_name))
|
|
for (const member of prev) {
|
|
if (kept.has(member.owner_name)) continue
|
|
calls.push({ kind: 'remove', owner: member.owner_name })
|
|
}
|
|
|
|
// `previousRole === 'admin'` is what makes it a handle: `callerOwners` lists every group
|
|
// the caller belongs to, and one holding only a viewer or writer row is not in `owners`,
|
|
// so removing it is an ordinary call that should not queue behind the fatal one.
|
|
const revokesCaller = (call: FolderPermissionCall) =>
|
|
(call.kind === 'demoteAdmin' || call.kind === 'remove') &&
|
|
(callerOwners?.includes(call.owner) ?? false) &&
|
|
previousRole.get(call.owner) === 'admin'
|
|
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
|
|
}
|