Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@placeos/cloud-uploads": "^1.1.1",
"@placeos/ts-client": "^6.5.0",
"@placeos/ts-client": "^6.7.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "22.0.0",
"@sentry/browser": "^10.25.0",
Expand Down
13 changes: 12 additions & 1 deletion public/assets/locale/en-AU.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,18 @@
"SWITCH_GROUP": "Switch group",
"ALL_GROUPS": "All groups",
"SEARCH_GROUPS": "Search groups",
"NO_MATCHING_GROUPS": "No matching groups"
"NO_MATCHING_GROUPS": "No matching groups",
"DEFAULT_PERMISSIONS": "Default permissions",
"DEFAULT_PERMISSIONS_HINT": "Permissions given to users added to this group without specific permissions.",
"AD_GROUPS": "AD group sync",
"AD_GROUPS_HINT": "Users in these AD groups are added to this group automatically with the set permissions.",
"AD_GROUPS_EMPTY": "No AD groups mapped to this group",
"AD_GROUP_SEARCH": "Search for AD groups...",
"AD_GROUP_ID": "AD group ID",
"AD_GROUP_NAME": "AD group name",
"AD_GROUP_ADD": "Add AD group",
"AD_GROUP_PERMISSIONS": "AD group permissions",
"AD_GROUP_REMOVE": "Remove AD group"
},
"DOMAINS": {
"SINGULAR": "Domain",
Expand Down
63 changes: 62 additions & 1 deletion src/app/groups/group-about.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { toSignal } from '../common/signals';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { groupPermissionLabels } from './group-permissions';
import { GroupStateService } from './group-state.service';

@Component({
Expand All @@ -21,7 +22,7 @@ import { GroupStateService } from './group-state.service';
<div class="w-full">
<div
class="border-base-200 grid gap-2 rounded-sm border p-4"
[style.gridTemplateColumns]="'8rem auto'"
[style.gridTemplateColumns]="'10rem auto'"
>
@if (item()?.authority_id) {
<div class="flex items-center text-sm font-medium">
Expand Down Expand Up @@ -73,6 +74,60 @@ import { GroupStateService } from './group-state.service';
}}</span>
}
</div>
<div class="flex items-center text-sm font-medium">
{{ 'GROUPS.DEFAULT_PERMISSIONS' | translate }}
</div>
<div class="-mx-1 flex flex-1 flex-wrap">
@for (
label of permissionLabels(
item()?.default_permissions || 0
);
track label
) {
<span
class="bg-base-200 m-1 rounded px-2 py-1 text-xs"
>
{{ label | translate }}
</span>
} @empty {
<span class="opacity-30">{{
'COMMON.NONE' | translate
}}</span>
}
</div>
<div class="flex items-center text-sm font-medium">
{{ 'GROUPS.AD_GROUPS' | translate }}
</div>
<div class="flex flex-col gap-1">
@for (ad_group of ad_groups(); track ad_group.id) {
<div class="flex flex-wrap items-center gap-1">
<span class="text-sm select-text">
{{ ad_group.name }}
</span>
<span
class="mono text-xs opacity-30 select-text"
>
{{ ad_group.id }}
</span>
@for (
label of permissionLabels(
ad_group.permissions
);
track label
) {
<span
class="bg-base-200 rounded px-2 py-1 text-xs"
>
{{ label | translate }}
</span>
}
</div>
} @empty {
<span class="opacity-30">{{
'GROUPS.AD_GROUPS_EMPTY' | translate
}}</span>
}
</div>
<div class="flex items-center text-sm font-medium">
{{ 'GROUPS.CHILDREN_COUNT' | translate }}
</div>
Expand Down Expand Up @@ -143,6 +198,12 @@ export class GroupAboutComponent {
});
public readonly parent = signal<PlaceGroup | null>(null);
public readonly authority = signal<PlaceDomain | null>(null);
public readonly permissionLabels = groupPermissionLabels;
public readonly ad_groups = computed(() =>
Object.entries(this.item()?.ad_group_mappings || {}).map(
([id, [name, permissions]]) => ({ id, name, permissions }),
),
);
public readonly created_at = computed(
() => Date.parse(this.item()?.created_at || '') / 1000,
);
Expand Down
217 changes: 217 additions & 0 deletions src/app/groups/group-ad-groups-field.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import {
Component,
computed,
inject,
input,
model,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceGroupAdMappings } from '@placeos/ts-client';
import { waitForEvent } from '../common/signals';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { groupPermissionLabels } from './group-permissions';
import { GroupPermissionsModalComponent } from './group-permissions-modal.component';
import {
removeAdGroupMapping,
searchStaffGroups,
setAdGroupMapping,
StaffDirectoryGroup,
} from './groups.utilities';

/**
* Edits the AD group mappings of a group.
* New mappings get `default_permissions`. When `searchable` is set the AD
* groups are found with the staff API, otherwise the ID and name are typed in.
*/
@Component({
selector: 'group-ad-groups-field',
template: `
<div class="flex flex-col gap-2">
@if (searchable()) {
<item-search-field
[placeholder]="'GROUPS.AD_GROUP_SEARCH' | translate"
[query_fn]="query_fn"
[exclude]="exclude_fn"
[clear_on_select]="true"
[ngModel]="null"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="addGroup($event)"
/>
} @else if (searchable() === false) {
<div class="flex items-center gap-2">
<mat-form-field
appearance="outline"
class="no-subscript flex-1"
>
<input
matInput
name="ad-group-id"
[placeholder]="'GROUPS.AD_GROUP_ID' | translate"
[(ngModel)]="new_id"
(keydown.enter)="addManual()"
/>
</mat-form-field>
<mat-form-field
appearance="outline"
class="no-subscript flex-1"
>
<input
matInput
name="ad-group-name"
[placeholder]="'GROUPS.AD_GROUP_NAME' | translate"
[(ngModel)]="new_name"
(keydown.enter)="addManual()"
/>
</mat-form-field>
<button
type="button"
btn
icon
matRipple
class="h-12 w-12"
[disabled]="!new_id().trim()"
[matTooltip]="'GROUPS.AD_GROUP_ADD' | translate"
(click)="addManual()"
>
<icon>add</icon>
</button>
</div>
}
@for (row of rows(); track row.id) {
<div
class="border-base-200 flex items-center gap-2 rounded-sm border px-4 py-2"
>
<div class="flex min-w-0 flex-1 flex-col">
<div class="truncate text-sm">{{ row.name }}</div>
<div class="mono truncate text-xs opacity-30">
{{ row.id }}
</div>
</div>
<div class="flex max-w-[50%] flex-wrap justify-end gap-1">
@for (
label of permissionLabels(row.permissions);
track label
) {
<span class="bg-base-200 rounded px-2 py-1 text-xs">
{{ label | translate }}
</span>
} @empty {
<span class="text-xs opacity-30">{{
'COMMON.NONE' | translate
}}</span>
}
</div>
<button
type="button"
icon
matRipple
[matTooltip]="'GROUPS.AD_GROUP_PERMISSIONS' | translate"
(click)="editPermissions(row)"
>
<icon>edit</icon>
</button>
<button
type="button"
icon
error
matRipple
[matTooltip]="'GROUPS.AD_GROUP_REMOVE' | translate"
(click)="removeGroup(row.id)"
>
<icon>delete</icon>
</button>
</div>
} @empty {
<p class="p-2 text-center text-sm opacity-30">
{{ 'GROUPS.AD_GROUPS_EMPTY' | translate }}
</p>
}
</div>
`,
styles: [``],
imports: [
FormsModule,
IconComponent,
ItemSearchFieldComponent,
MatFormFieldModule,
MatInputModule,
MatRippleModule,
MatTooltipModule,
TranslatePipe,
],
})
export class GroupAdGroupsFieldComponent {
private _dialog = inject(MatDialog);

public readonly mappings = model<PlaceGroupAdMappings>({});
public readonly default_permissions = input(0);
/** Unset while the form checks for staff API search */
public readonly searchable = input<boolean>();

public readonly new_id = signal('');
public readonly new_name = signal('');
public readonly permissionLabels = groupPermissionLabels;
public readonly rows = computed(() =>
Object.entries(this.mappings())
.map(([id, [name, permissions]]) => ({ id, name, permissions }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
public readonly query_fn = (q: string) => searchStaffGroups(q);
public readonly exclude_fn = (group: StaffDirectoryGroup) =>
!!this.mappings()[group.id.trim().toLowerCase()];

/** Maps a new AD group with the default permissions. Existing ones are kept */
public addGroup(group: StaffDirectoryGroup | null) {
if (!group?.id || this.mappings()[group.id.trim().toLowerCase()]) {
return;
}
this.mappings.update((mappings) =>
setAdGroupMapping(
mappings,
group.id,
group.name,
this.default_permissions(),
),
);
}

public addManual() {
if (!this.new_id().trim()) return;
this.addGroup({ id: this.new_id(), name: this.new_name() });
this.new_id.set('');
this.new_name.set('');
}

public removeGroup(id: string) {
this.mappings.update((mappings) => removeAdGroupMapping(mappings, id));
}

public async editPermissions(row: {
id: string;
name: string;
permissions: number;
}) {
const result = await waitForEvent(
this._dialog
.open(GroupPermissionsModalComponent, {
data: {
title: 'GROUPS.AD_GROUP_PERMISSIONS',
permissions: row.permissions,
},
})
.afterClosed(),
);
if (!result) return;
this.mappings.update((mappings) =>
setAdGroupMapping(mappings, row.id, row.name, result.permissions),
);
}
}
4 changes: 3 additions & 1 deletion src/app/groups/group-bulk-add-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface GroupBulkAddModalData<T extends GroupBulkAddItem> {
query_fn: (query: string) => Promise<T[]>;
exclude?: (item: T, search: string) => boolean;
show_permissions?: boolean;
/** Initial permissions for the added items */
permissions?: number;
}

export interface GroupBulkAddResult<T extends GroupBulkAddItem> {
Expand Down Expand Up @@ -229,7 +231,7 @@ export class GroupBulkAddModalComponent<T extends GroupBulkAddItem> {
public readonly selected = signal<T[]>([]);
public readonly search = signal('');
public readonly loading = signal(false);
public readonly permissions = signal(0);
public readonly permissions = signal(this._data.permissions ?? 0);
public readonly tab = signal(0);
public readonly title = this._data.title;
public readonly placeholder = this._data.placeholder;
Expand Down
Loading
Loading