Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ef45cc2
fix(triggers): build and parse valid cron schedules
MrYuion Sep 30, 2026
9035955
fix(triggers): repair instance list, mailer reorder and zone removal
MrYuion Sep 30, 2026
381c3a0
fix(drivers): repair module removal, update checks and driver form
MrYuion Sep 30, 2026
5e0c20b
fix(systems): keep tab state per system and stop form resets
MrYuion Sep 30, 2026
5875063
fix(zones): load full lists and fix user, group and zone forms
MrYuion Sep 30, 2026
c6f054d
fix(overlays): repair metadata, duplicate, bulk add and auth modals
MrYuion Sep 30, 2026
17d7c12
fix(systems): use a strict check for the Public row
MrYuion Sep 30, 2026
3a34f4e
fix(admin): repair tenant expiry, imports, uploads and cluster lookup
MrYuion Sep 30, 2026
9189c1b
fix(app): update shell state for tenants, uploads, offline and locale
MrYuion Sep 30, 2026
5dd4b8e
fix(app): load full browser locale tag again
MrYuion Sep 30, 2026
b6599b2
fix(triggers): save custom cron at once and stop date fields submitti…
MrYuion Sep 30, 2026
1575856
fix(app): show offline, lang and secret expiry banners
MrYuion Sep 30, 2026
a299beb
fix(admin): clear outlook config when the toggle is off
MrYuion Sep 30, 2026
5683c00
fix(systems): use renamed warn colour for changed zones
MrYuion Sep 30, 2026
1e93483
fix(drivers): refresh module count and name module in remove confirm
MrYuion Sep 30, 2026
560cc7e
fix(repositories): block save when branch is empty
MrYuion Sep 30, 2026
57dfaff
fix(ui): dispose diff models after the editor swaps them
MrYuion Sep 30, 2026
20a0dfa
fix: clear outlook config and name modules in system remove confirm
MrYuion Sep 30, 2026
9832af3
fix(groups): drop parent clear button
MrYuion Sep 30, 2026
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
2 changes: 2 additions & 0 deletions public/assets/locale/en-AU.json
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,8 @@
"RESOURCE_IMPORTS_ALL_LOADING": "Importing missing resources...",
"RESOURCE_IMPORTS_ALL_SUCCESS": "Successfully imported {{ count }} resources.",
"RESOURCE_IMPORTS_SUCCESS": "Successfully imported resource {{ name }}.",
"RESOURCE_IMPORTS_ERROR": "Failed to import resource {{ name }}. Error: {{ error }}",
"RESOURCE_IMPORTS_ALL_ERROR": "Failed to import {{ count }} resources.",
"EXTENSIONS_HEADER": "Extensions",
"EXTENSIONS_ADD": "Add Extension",
"EXTENSIONS_NEW": "New Extension",
Expand Down
5 changes: 3 additions & 2 deletions src/app/admin/cluster-details/cluster-task-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,10 @@ export class PlaceClusterTaskListComponent
const clusters = await queryClusters({
q: id,
} as Record<string, string>).then((_) => _.data);
const match = clusters.find((_) => _.id === id) || clusters[0];
console.log('Clusters:', clusters);
// The search may return other clusters, so only accept an exact id
const match = clusters.find((_) => _.id === id) || null;
this.cluster.set(match);
if (!match) this.process_list.set([]);
this.updateProcessList();
}

Expand Down
80 changes: 59 additions & 21 deletions src/app/admin/resource-imports.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import { RouterModule } from '@angular/router';
import { addSystem, query, querySystemsWithEmails } from '@placeos/ts-client';
import { escapeHtml } from '../common/general';
import { i18n } from '../common/locale.service';
import { notifySuccess, notifyWarn } from '../common/notifications';
import {
notifyError,
notifySuccess,
notifyWarn,
} from '../common/notifications';
import { openConfirmModal } from '../overlays/confirm-modal.component';
import { IconComponent } from '../ui/icon.component';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { AdminDataService } from './admin-data.service';
import { forEachWithLimit } from './signage-plugins/signage-plugins.utilities';

/** Max number of systems created at once */
const IMPORT_LIMIT = 5;

export interface ExternalResource {
id: string;
Expand Down Expand Up @@ -227,38 +235,68 @@ export class ResourceImportsComponent implements OnInit {

if (resp?.reason !== 'done') return;
resp.loading(i18n('ADMIN.RESOURCE_IMPORTS_ALL_LOADING'));
try {
await Promise.all(
missing.map((_) => this.importResource(_, false)),
let failed = 0;
await forEachWithLimit(missing, IMPORT_LIMIT, async (resource) => {
if (!(await this.importResource(resource, false))) failed += 1;
});
resp.close();
if (failed) {
notifyError(
i18n('ADMIN.RESOURCE_IMPORTS_ALL_ERROR', { count: failed }),
);
}
if (missing.length > failed) {
notifySuccess(
i18n('ADMIN.RESOURCE_IMPORTS_ALL_SUCCESS', {
count: missing.length - failed,
}),
);
} finally {
resp.close();
}
notifySuccess(
i18n('ADMIN.RESOURCE_IMPORTS_ALL_SUCCESS', {
count: missing.length,
}),
);
}

public async importResource(resource: ExternalResource, notify = true) {
/**
* Create a system for the resource.
* Returns whether the import succeeded.
*/
public async importResource(
resource: ExternalResource,
notify = true,
): Promise<boolean> {
const domain = this.domain();
if (!domain) return;
if (!domain) return false;
const system = await addSystem({
name: `[${domain.name}] ${resource.display_name}`,
email: resource.email,
display_name: resource.display_name,
capacity: resource.capacity,
}).catch((error) => {
if (notify) {
notifyError(
i18n('ADMIN.RESOURCE_IMPORTS_ERROR', {
name: resource.display_name,
error: error?.message || error,
}),
);
}
return null;
});
if (!system) return;
resource.system_id = system.id;
resource.imported = true;
if (!notify) return;
notifySuccess(
i18n('ADMIN.RESOURCE_IMPORTS_SUCCESS', {
name: resource.display_name,
}),
if (!system) return false;
// Replace the row so the zoneless table updates
this.resource_list.update((list) =>
list.map((item) =>
item.email === resource.email
? { ...item, system_id: system.id, imported: true }
: item,
),
);
if (notify) {
notifySuccess(
i18n('ADMIN.RESOURCE_IMPORTS_SUCCESS', {
name: resource.display_name,
}),
);
}
return true;
}

public async loadResourceList() {
Expand Down
16 changes: 4 additions & 12 deletions src/app/admin/staff-api.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { del, get, PlaceDomain } from '@placeos/ts-client';
import { addDays, getUnixTime, startOfDay } from 'date-fns';
import { escapeHtml } from '../common/general';
import { notifyError, notifySuccess } from '../common/notifications';
import { HashMap } from '../common/types';
Expand All @@ -19,6 +18,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
import { AdminDataService } from './admin-data.service';
import { BookingLimitsModalComponent } from './booking-limits-modal.component';
import { StaffTenantModalComponent } from './staff-tenant-modal.component';
import { isSecretExpired, isSecretExpiring } from './staff-api.utilities';

export interface PlaceTenant {
id: string;
Expand Down Expand Up @@ -202,17 +202,9 @@ export class PlaceStaffAPIComponent implements OnInit {
public readonly domain = this._admin_data.selectedDomain('staff-api');
public readonly tenants = signal<PlaceTenant[]>([]);

public expiring(tenant: PlaceTenant): boolean {
const expiry = tenant.secret_expiry;
const after_time = getUnixTime(startOfDay(addDays(Date.now(), -30)));
return expiry && expiry >= after_time;
}

public expired(tenant: PlaceTenant): boolean {
const expiry = tenant.secret_expiry;
const after_time = getUnixTime(Date.now());
return expiry && expiry >= after_time;
}
public readonly expiring = (tenant: PlaceTenant) =>
isSecretExpiring(tenant);
public readonly expired = (tenant: PlaceTenant) => isSecretExpired(tenant);

public async ngOnInit() {
this.loading.set('Loading domains...');
Expand Down
53 changes: 53 additions & 0 deletions src/app/admin/staff-api.utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { addDays, format, getUnixTime } from 'date-fns';
import type { BannerDetails } from '../ui/global-banner.component';
import type { PlaceTenant } from './staff-api.component';

/** Days before expiry that a tenant secret counts as expiring */
export const SECRET_EXPIRY_WARNING_DAYS = 30;

/** Whether the tenant's secret has passed its expiry time */
export function isSecretExpired(
tenant: Pick<PlaceTenant, 'secret_expiry'>,
now = Date.now(),
): boolean {
const expiry = tenant.secret_expiry;
return !!expiry && expiry < getUnixTime(now);
}

/** Whether the tenant's secret expires soon. Includes expired secrets. */
export function isSecretExpiring(
tenant: Pick<PlaceTenant, 'secret_expiry'>,
now = Date.now(),
): boolean {
const expiry = tenant.secret_expiry;
const warn_after = getUnixTime(addDays(now, SECRET_EXPIRY_WARNING_DAYS));
return !!expiry && expiry < warn_after;
}

/**
* Build one banner for all tenants with expiring secrets.
* Returns `null` when no secrets expire soon.
*/
export function tenantExpiryBanner(
tenants: PlaceTenant[],
now = Date.now(),
): BannerDetails | null {
const expiring = (tenants || []).filter((_) => isSecretExpiring(_, now));
if (!expiring.length) return null;
const details = expiring.map(
(tenant) =>
`"${tenant.name}" (${format(
tenant.secret_expiry * 1000,
"MMM do 'at' h:mma",
)})`,
);
return {
id: `tenant_secret_expiry-${expiring
.map((_) => `${_.id}:${_.secret_expiry}`)
.join(',')}`,
type: expiring.some((_) => isSecretExpired(_, now)) ? 'error' : 'warn',
content: `Staff API tenant secrets expire soon or have expired: ${details.join(
', ',
)}.`,
};
}
9 changes: 8 additions & 1 deletion src/app/admin/staff-tenant-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,11 @@ export class StaffTenantModalComponent {
public readonly tenant = this._data.tenant;
public readonly domain = this._data.domain;
public readonly loading = signal('');
public readonly show_outlook = model(false);
// The backend stores a cleared config as `{ app_id: '' }`, so check app_id
public readonly show_outlook = model(
!!((this.tenant || {}) as { outlook_config?: HashMap }).outlook_config
?.app_id,
);
public readonly name_map = FIELD_NAME_MAPPING;
public readonly outlook_fields = [
'app_id',
Expand Down Expand Up @@ -578,6 +582,9 @@ export class StaffTenantModalComponent {
},
['', null, undefined],
);
// staff-api ignores null, so send an empty config to clear it.
// It is stored as `{ app_id: '' }`, which reads as off.
if (!this.show_outlook()) data.outlook_config = {};
const call = this.tenant?.id
? put(`/api/staff/v1/tenants/${this.tenant.id}`, data)
: post('/api/staff/v1/tenants', data);
Expand Down
28 changes: 24 additions & 4 deletions src/app/admin/upload-library.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export interface UploadInfo {
uploaded_email: number;
}

/** Interval between upload progress checks */
const UPLOAD_POLL_MS = 250;
/** Stop checking upload progress after one hour */
const MAX_UPLOAD_POLLS = (60 * 60 * 1000) / UPLOAD_POLL_MS;

@Component({
selector: 'upload-library',
template: `
Expand Down Expand Up @@ -462,8 +467,16 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit {
this._uploads.uploadFileWithPermissions(files[i]),
);
}
const id_list = await Promise.all(uploads);
// Cancelling the permissions modal rejects that file only
const results = await Promise.allSettled(uploads);
const id_list = results
.filter((_) => _.status === 'fulfilled')
.map((_) => (_ as PromiseFulfilledResult<number>).value);
if (!id_list.length) return;
this.loading.set(true);
let polls = 0;
const checkUploads = () => {
polls += 1;
const list = this._uploads.upload_list();
let success = 0;
let failed = 0;
Expand All @@ -474,15 +487,22 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit {
if (upload.error) failed += 1;
else if (upload.progress >= 100) success += 1;
}
if (success + failed >= id_list.length) {
const finished = success + failed >= id_list.length;
if (finished || polls >= MAX_UPLOAD_POLLS) {
if (failed) {
notifyError('Failed to upload files.');
} else if (success) {
} else if (success && finished) {
notifySuccess('Succesfully uploaded files.');
}
this.clearTimeout('upload_list');
this.loading.set(false);
if (success) this.refresh.update((_) => _ + 1);
} else {
this.timeout('upload_list', checkUploads, 250);
this.timeout(
'upload_list',
checkUploads,
UPLOAD_POLL_MS,
);
}
};
checkUploads();
Expand Down
Loading
Loading