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
16 changes: 12 additions & 4 deletions src/app/admin/api-keys/api-keys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ import { AdminDataService } from '../admin-data.service';
import { PlaceAPIKeyDetails } from './api-key-details.class';
import { APIKeyModalComponent } from './api-key-modal.component';

/**
* Expiry for a new API key in unix seconds, or `undefined` if it never expires.
* An explicit `expires_at` wins. Otherwise `ttl` (seconds) counts from `now` (ms).
*/
export function apiKeyExpiry(
{ expires_at, ttl }: { expires_at?: number | null; ttl?: number | null },
now = Date.now(),
): number | undefined {
return expires_at ?? (ttl ? getUnixTime(now) + ttl : undefined);
}

@Service()
export class APIKeyService {
private _dialog = inject(MatDialog);
Expand Down Expand Up @@ -120,10 +131,7 @@ export class APIKeyService {
path: 'api_keys',
form_data: {
...api_key,
expires_at:
(api_key.expires_at ?? api_key.ttl)
? getUnixTime(Date.now() + api_key.ttl)
: undefined,
expires_at: apiKeyExpiry(api_key),
authority_id: domain.id,
},
}).catch((_) => {
Expand Down
10 changes: 3 additions & 7 deletions src/app/admin/build-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Component, inject, OnInit, signal } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
Expand Down Expand Up @@ -55,7 +55,7 @@ function cancelBuildJob(id, q = {}) {
/>
<simple-table
class="block min-w-5xl text-sm"
[data]="jobs()"
[data]="job_list()"
[columns]="[
{
key: 'repo',
Expand Down Expand Up @@ -144,12 +144,8 @@ export class PlaceBuildListComponent implements OnInit {
private _dialog = inject(MatDialog);

public readonly loading = signal('');
public readonly hide_job = signal('');
public readonly last_change = signal<BuildJob>(null);
public readonly job_list = signal<BuildJob[]>([]);
public readonly jobs = computed(() => {
return this.job_list().filter(({ id }) => id !== this.hide_job());
});

public ngOnInit() {
this.loadJobList();
Expand Down Expand Up @@ -184,7 +180,7 @@ export class PlaceBuildListComponent implements OnInit {
);
this.last_change.set(null);
notifySuccess(i18n('ADMIN.BUILD_LIST_REMOVE_SUCCESS'));
this.hide_job.set(i.id);
this.job_list.update((list) => list.filter(({ id }) => id !== i.id));
}

public async loadJobList() {
Expand Down
27 changes: 19 additions & 8 deletions src/app/admin/database-details.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
notifySuccess,
notifyWarn,
} from '../common/notifications';
import { describeError } from '../overlays/confirm-modal.component';
import { ZoneTreeExportModalComponent } from './zone-tree-export-modal.component';

type ZoneTreeExportItem = Record<string, unknown> & {
Expand Down Expand Up @@ -259,15 +260,25 @@ export class PlaceDatabaseDetailsComponent {
return;
}
const id_map = new Map<string, string>();
for (const zone of zones) {
if (!zone.id) continue;
const parent_id = zone.parent_id
? id_map.get(zone.parent_id) || ''
: '';
const created_zone = await addZone(
zoneToImportItem(zone, parent_id),
try {
for (const zone of zones) {
if (!zone.id) continue;
const parent_id = zone.parent_id
? id_map.get(zone.parent_id) || ''
: '';
const created_zone = await addZone(
zoneToImportItem(zone, parent_id),
);
id_map.set(zone.id, created_zone.id);
}
} catch (err) {
// Zones created before the error stay, so tell the user how far it got
notifyError(
`Error importing zone tree. Created ${id_map.size} of ${
zones.length
} zones before the error. Error: ${describeError(err)}`,
);
id_map.set(zone.id, created_zone.id);
return;
}
notifySuccess(`Imported ${id_map.size} zones.`);
}
Expand Down
12 changes: 5 additions & 7 deletions src/app/admin/edge.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Clipboard } from '@angular/cdk/clipboard';

import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Component, inject, OnInit, signal } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
Expand Down Expand Up @@ -72,7 +72,7 @@ import { EdgeModalComponent } from './edge-modal.component';
/>
<simple-table
class="block min-w-5xl text-sm"
[data]="edges()"
[data]="edge_list()"
[columns]="[
{
key: 'online',
Expand Down Expand Up @@ -192,11 +192,7 @@ export class PlaceEdgeComponent implements OnInit {
private _clipboard = inject(Clipboard);

public readonly loading = signal('');
public readonly hide_edge = signal('');
public readonly edge_list = signal<PlaceEdge[]>([]);
public readonly edges = computed(() => {
return this.edge_list().filter(({ id }) => id !== this.hide_edge());
});
public readonly last_change = signal<PlaceEdge>(null);

public ngOnInit() {
Expand All @@ -218,6 +214,8 @@ export class PlaceEdgeComponent implements OnInit {
public async edit(edge?: PlaceEdge) {
const ref = this._dialog.open(EdgeModalComponent, { data: { edge } });
ref.afterClosed().subscribe((_) => {
// Cancel keeps the last saved edge and its one-time API key
if (!_) return;
sessionStorage.setItem('BACKOFFICE.last_edge', JSON.stringify(_));
this.last_change.set(_);
this.loadEdges();
Expand Down Expand Up @@ -246,7 +244,7 @@ export class PlaceEdgeComponent implements OnInit {
sessionStorage.removeItem('BACKOFFICE.last_edge');
this.last_change.set(null);
notifySuccess('Successfully removed Edge.');
this.hide_edge.set(i.id);
this.edge_list.update((list) => list.filter(({ id }) => id !== i.id));
}

public copyKey(key: string) {
Expand Down
16 changes: 13 additions & 3 deletions src/app/admin/extensions.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface BackofficeExtension {
icon: ApplicationIcon;
}

/** Extensions are keyed by type and name, so the same name can exist in each type */
const isSameExtension = (a: BackofficeExtension, b?: BackofficeExtension) =>
a.type === b?.type && a.name === b?.name;

@Component({
selector: '[app-extensions]',
template: `
Expand Down Expand Up @@ -238,9 +242,15 @@ export class PlaceExtensionsComponent implements OnInit {
).then(async (event) => {
ref.componentInstance.loading.set('Saving backoffice extension...');
let ext_list = this.extensions() || [];
ext_list = ext_list.filter((i) => i.name !== item?.name);
ext_list = ext_list.filter((i) => !isSameExtension(i, item));
ext_list.push(event.metadata as BackofficeExtension);
await this.updateDomain(ext_list);
try {
await this.updateDomain(ext_list);
} catch (e) {
ref.componentInstance.loading.set('');
notifyError(`Error saving extension: ${e}`);
return;
}
ref.componentInstance.loading.set('');
ref.close();
});
Expand All @@ -263,7 +273,7 @@ export class PlaceExtensionsComponent implements OnInit {
).then(async (__) => {
ref.componentInstance.loading.set('Removing extension...');
let ext_list = this.extensions();
ext_list = ext_list.filter((i) => i.name !== item.name);
ext_list = ext_list.filter((i) => !isSameExtension(i, item));
await this.updateDomain(ext_list).catch((e) =>
notifyError(`Error removing extension: ${e}`),
);
Expand Down
98 changes: 52 additions & 46 deletions src/app/common/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,10 @@ export function numberToPosition(num: number): string {
export const csvToJson = parseCSV;
/**
* Parses a CSV string back into an array of JavaScript objects.
* - It assumes the first line is the header row.
* - It assumes the first row is the header row.
* - Attempts JSON.parse on each cell. If parsing fails, keeps the raw string.
* - Handles basic quote escaping ("" -> ").
* - Handles quoted cells with separators, escaped quotes ("" -> ") and newlines.
* - Accepts `\n` and `\r\n` line endings.
*
* @param csv - The CSV string.
* @param separator - The delimiter (comma by default).
Expand All @@ -194,16 +195,10 @@ export function parseCSV(
csv: string,
separator = ',',
): Record<string, unknown>[] {
// Split on newlines, remove any empty lines
const lines = csv.split('\n').filter((line) => line.trim() !== '');
if (!lines.length) return [];

const [headerLine, ...dataLines] = lines;
const headers = splitCsvLine(headerLine, separator);

return dataLines.map((line) => {
const cells = splitCsvLine(line, separator);
const [headers, ...data_rows] = splitCsvRows(csv, separator);
if (!headers) return [];

return data_rows.map((cells) => {
const record: Record<string, unknown> = {};

headers.forEach((header, idx) => {
Expand All @@ -223,50 +218,60 @@ export function parseCSV(
}

/**
* Splits a CSV line into cells, handling:
* - quoted strings
* - escaped quotes
*
* This is a simplified parser that expects CSV in the format produced by `jsonToCSV`.
* For more robust parsing (multiline fields, etc.), consider a specialized library.
* Splits CSV text into rows of cells in one pass over the characters.
* Quotes are tracked across lines, so a quoted cell can hold separators,
* escaped quotes ("") and newlines. Blank rows are removed.
*/
function splitCsvLine(line: string, separator: string): string[] {
const cells: string[] = [];
let current = '';
let inQuotes = false;

for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];

if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote ("")
current += '"';
i++; // Skip the next quote
function splitCsvRows(csv: string, separator: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let in_quotes = false;
const endRow = () => {
row.push(cell);
if (row.length > 1 || row[0].trim() !== '') rows.push(row);
row = [];
cell = '';
};

// `i` only moves forward, so the loop ends after `csv.length` steps
for (let i = 0; i < csv.length; i++) {
const char = csv[i];
if (in_quotes) {
if (char !== '"') {
cell += char;
} else if (csv[i + 1] === '"') {
cell += '"';
i++;
} else {
// Toggle quote mode
inQuotes = !inQuotes;
in_quotes = false;
}
} else if (char === separator && !inQuotes) {
// End of current cell
cells.push(current);
current = '';
} else if (char === '"') {
in_quotes = true;
} else if (separator && csv.startsWith(separator, i)) {
row.push(cell);
cell = '';
i += separator.length - 1;
} else if (char === '\n') {
endRow();
} else if (char === '\r' && csv[i + 1] === '\n') {
endRow();
i++;
} else {
current += char;
cell += char;
}
}
endRow();

// Push the last cell
cells.push(current);

return cells;
return rows;
}

/**
* Converts an array of JSON objects into a CSV string.
*
* @param data - The JSON array to convert.
* @param use_keys - Columns to output, in order. When empty, uses the union
* of keys across all rows, in first-seen order.
* @param separator - The optional field separator (comma by default).
* @returns A string in CSV format.
*/
Expand All @@ -277,9 +282,9 @@ export function jsonToCsv<T extends Record<string, unknown>>(
): string {
if (!data.length) return '';

const headers = Object.keys(data[0]).filter(
(key) => !use_keys.length || use_keys.includes(key),
);
const headers = use_keys.length
? use_keys
: [...new Set(data.flatMap((item) => Object.keys(item)))];
const headerRow = headers.join(separator);

const rows = data.map((item) => {
Expand All @@ -299,7 +304,8 @@ export function jsonToCsv<T extends Record<string, unknown>>(
if (
cellStr.includes(separator) ||
cellStr.includes('"') ||
cellStr.includes('\n')
cellStr.includes('\n') ||
cellStr.includes('\r')
) {
// Escape quotes
const escaped = cellStr.replace(/"/g, '""');
Expand Down
Loading
Loading