feat: add frontend components and OpenAPI spec for permission history

- Add OpenAPI endpoints for folder and group permission history
- Create reusable PermissionHistory component with Svelte 5
- Integrate history display into FolderEditor (for folder admins)
- Integrate history display into GroupEditor (for workspace admins)
- Fix ownership issues in folders.rs and granular_acls.rs
- Frontend validation passes (npm run check)

Note: SQLx query cache update required (cargo sqlx prepare)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-10-30 12:13:19 +00:00
parent f4820f1417
commit e9b83341e9
6 changed files with 206 additions and 2 deletions
+68
View File
@@ -11995,6 +11995,40 @@ paths:
schema:
type: string
/w/{workspace}/groups_history/get/{name}:
get:
summary: get group permission history
operationId: getGroupPermissionHistory
tags:
- group
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Name"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: group permission history
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
changed_by:
type: string
changed_at:
type: string
format: date-time
change_type:
type: string
member_affected:
type: string
nullable: true
/w/{workspace}/folders/list:
get:
summary: list folders
@@ -12258,6 +12292,40 @@ paths:
schema:
type: string
/w/{workspace}/folders_history/get/{name}:
get:
summary: get folder permission history
operationId: getFolderPermissionHistory
tags:
- folder
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Name"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: folder permission history
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
changed_by:
type: string
changed_at:
type: string
format: date-time
change_type:
type: string
owner_affected:
type: string
nullable: true
/workers/list:
get:
summary: list workers
+6 -1
View File
@@ -329,6 +329,10 @@ async fn update_folder(
sqlb.set("edited_at", "now()");
// Track whether permission-related fields are being updated
let owners_changed = ng.owners.is_some();
let extra_perms_changed = ng.extra_perms.is_some();
if !authed.is_admin {
let prefixed_username = format!("u/{}", authed.username);
if ng.owners.as_ref().is_some_and(|x| {
@@ -417,7 +421,8 @@ async fn update_folder(
.await?;
// Log permission changes if owners or extra_perms were updated
if ng.owners.is_some() || ng.extra_perms.is_some() {
let should_log = owners_changed || extra_perms_changed;
if should_log {
log_folder_permission_change(
&mut *tx,
&w_id,
+1 -1
View File
@@ -116,7 +116,7 @@ async fn add_granular_acl(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
))
.bind(vec![owner])
.bind(vec![owner.clone()])
.bind(write.unwrap_or(false))
.bind(path)
.bind(&w_id)
@@ -20,6 +20,7 @@
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import PermissionHistory from './PermissionHistory.svelte'
interface Props {
name: string
@@ -441,4 +442,19 @@
{/if}
</div>
</Label>
{#if can_write}
<PermissionHistory
{name}
kind="folder"
fetchHistory={async (workspace, folderName, page, perPage) => {
return await FolderService.getFolderPermissionHistory({
workspace,
name: folderName,
page,
perPage
})
}}
/>
{/if}
</div>
@@ -20,6 +20,7 @@
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Trash } from 'lucide-svelte'
import PermissionHistory from './PermissionHistory.svelte'
interface Props {
name: string
@@ -311,4 +312,19 @@
</div>
{/if}
</Label>
{#if $userStore?.is_admin}
<PermissionHistory
{name}
kind="group"
fetchHistory={async (workspace, groupName, page, perPage) => {
return await GroupService.getGroupPermissionHistory({
workspace,
name: groupName,
page,
perPage
})
}}
/>
{/if}
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import TableCustom from './TableCustom.svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Label from './Label.svelte'
interface PermissionChange {
id?: number
changed_by?: string
changed_at?: string
change_type?: string
owner_affected?: string | null
member_affected?: string | null
}
interface Props {
name: string
kind: 'folder' | 'group'
fetchHistory: (
workspace: string,
name: string,
page: number,
perPage: number
) => Promise<PermissionChange[]>
}
let { name, kind, fetchHistory }: Props = $props()
let history: PermissionChange[] | undefined = $state(undefined)
let loading = $state(false)
let page = $state(1)
let perPage = $state(10)
async function loadHistory() {
if (!$workspaceStore) return
loading = true
try {
history = await fetchHistory($workspaceStore, name, page, perPage)
} catch (e) {
console.error('Failed to load permission history:', e)
history = []
} finally {
loading = false
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleString()
}
function formatChangeType(changeType: string): string {
return changeType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
$effect.pre(() => {
if ($workspaceStore && name) {
untrack(() => {
loadHistory()
})
}
})
</script>
<Label label="Permission History">
{#if loading || history === undefined}
<div class="flex flex-col gap-2">
{#each new Array(3) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
</div>
{:else if history.length === 0}
<p class="text-primary text-sm">No permission changes recorded yet</p>
{:else}
<TableCustom>
<tr slot="header-row">
<th>Changed By</th>
<th>Change Type</th>
<th>{kind === 'folder' ? 'Owner Affected' : 'Member Affected'}</th>
<th>Date</th>
</tr>
{#snippet body()}
<tbody>
{#each history as change}
<tr>
<td>{change.changed_by ?? '-'}</td>
<td>{change.change_type ? formatChangeType(change.change_type) : '-'}</td>
<td>{change.owner_affected ?? change.member_affected ?? '-'}</td>
<td class="text-xs">{change.changed_at ? formatDate(change.changed_at) : '-'}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
{/if}
</Label>