@nauticana/sail 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +741 -0
  3. package/package.json +58 -0
  4. package/src/component/abstract/base_async.ts +49 -0
  5. package/src/component/abstract/base_form.ts +11 -0
  6. package/src/component/abstract/base_table.ts +320 -0
  7. package/src/component/abstract/base_view.ts +93 -0
  8. package/src/component/billing/checkout_button.html +10 -0
  9. package/src/component/billing/checkout_button.ts +57 -0
  10. package/src/component/billing/payment_methods.html +27 -0
  11. package/src/component/billing/payment_methods.ts +36 -0
  12. package/src/component/billing/plan_selector.html +24 -0
  13. package/src/component/billing/plan_selector.ts +29 -0
  14. package/src/component/form/form_field.html +54 -0
  15. package/src/component/form/form_field.ts +156 -0
  16. package/src/component/form/form_record.html +14 -0
  17. package/src/component/form/form_record.ts +56 -0
  18. package/src/component/form/form_table.html +19 -0
  19. package/src/component/form/form_table.ts +58 -0
  20. package/src/component/login/account_deletion.html +42 -0
  21. package/src/component/login/account_deletion.ts +79 -0
  22. package/src/component/login/chpass_component.html +58 -0
  23. package/src/component/login/chpass_component.ts +96 -0
  24. package/src/component/login/confirm_chpass_component.html +47 -0
  25. package/src/component/login/confirm_chpass_component.ts +70 -0
  26. package/src/component/login/confirm_register_component.html +37 -0
  27. package/src/component/login/confirm_register_component.ts +71 -0
  28. package/src/component/login/consent_gate.html +30 -0
  29. package/src/component/login/consent_gate.ts +109 -0
  30. package/src/component/login/login_component.html +43 -0
  31. package/src/component/login/login_component.ts +73 -0
  32. package/src/component/login/otp_input.html +38 -0
  33. package/src/component/login/otp_input.ts +104 -0
  34. package/src/component/login/register_component.html +132 -0
  35. package/src/component/login/register_component.ts +184 -0
  36. package/src/component/login/social_login.html +13 -0
  37. package/src/component/login/social_login.ts +120 -0
  38. package/src/component/navigation/navigation.html +49 -0
  39. package/src/component/navigation/navigation.ts +69 -0
  40. package/src/component/security/trusted_devices.html +76 -0
  41. package/src/component/security/trusted_devices.ts +92 -0
  42. package/src/component/security/twofactor_setup.html +75 -0
  43. package/src/component/security/twofactor_setup.ts +106 -0
  44. package/src/component/security/twofactor_verify.html +45 -0
  45. package/src/component/security/twofactor_verify.ts +90 -0
  46. package/src/component/table/table_detail.html +53 -0
  47. package/src/component/table/table_detail.ts +133 -0
  48. package/src/component/table/table_edit.html +58 -0
  49. package/src/component/table/table_edit.ts +163 -0
  50. package/src/component/table/table_list.html +41 -0
  51. package/src/component/table/table_list.ts +100 -0
  52. package/src/component/table/table_lookup.html +35 -0
  53. package/src/component/table/table_lookup.ts +88 -0
  54. package/src/component/table/table_search.html +17 -0
  55. package/src/component/table/table_search.ts +80 -0
  56. package/src/config.ts +67 -0
  57. package/src/index.ts +41 -0
  58. package/src/model/appdata.ts +266 -0
  59. package/src/model/auth.ts +134 -0
  60. package/src/model/common.ts +115 -0
  61. package/src/model/decorator.ts +74 -0
  62. package/src/service/api-response.interceptor.ts +26 -0
  63. package/src/service/auth.interceptor.ts +11 -0
  64. package/src/service/auth.service.ts +576 -0
  65. package/src/service/billing.service.ts +65 -0
  66. package/src/service/label.service.ts +18 -0
  67. package/src/service/rest_service.ts +94 -0
  68. package/src/service/rest_url.ts +42 -0
  69. package/src/service/script_loader.ts +31 -0
  70. package/tsconfig.json +16 -0
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@nauticana/sail",
3
+ "version": "0.5.0",
4
+ "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing.",
5
+ "main": "src/index.ts",
6
+ "files": [
7
+ "src/",
8
+ "tsconfig.json"
9
+ ],
10
+ "keywords": [
11
+ "angular",
12
+ "angular-library",
13
+ "crud",
14
+ "admin",
15
+ "keel",
16
+ "nauticana",
17
+ "metadata-driven",
18
+ "material",
19
+ "auth",
20
+ "2fa",
21
+ "otp"
22
+ ],
23
+ "license": "Apache-2.0",
24
+ "author": "Nauticana",
25
+ "homepage": "https://github.com/nauticana/sail#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/nauticana/sail/issues"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/nauticana/sail.git"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "peerDependencies": {
37
+ "@angular/cdk": "^21.0.0",
38
+ "@angular/common": "^21.0.0",
39
+ "@angular/core": "^21.0.0",
40
+ "@angular/forms": "^21.0.0",
41
+ "@angular/material": "^21.0.0",
42
+ "@angular/router": "^21.0.0",
43
+ "class-validator": "^0.15.1",
44
+ "rxjs": "~7.8.0"
45
+ },
46
+ "devDependencies": {
47
+ "@angular/cdk": "^21.2.6",
48
+ "@angular/common": "^21.2.8",
49
+ "@angular/compiler": "^21.2.8",
50
+ "@angular/core": "^21.2.8",
51
+ "@angular/forms": "^21.2.8",
52
+ "@angular/material": "^21.2.6",
53
+ "@angular/router": "^21.2.8",
54
+ "class-validator": "^0.15.1",
55
+ "rxjs": "~7.8.0",
56
+ "typescript": "~5.9.2"
57
+ }
58
+ }
@@ -0,0 +1,49 @@
1
+ import { signal } from '@angular/core';
2
+ import { Observable } from 'rxjs';
3
+
4
+ /**
5
+ * Base class for components that perform async operations and need to show
6
+ * loading / error / success state. Reduces the same signal boilerplate
7
+ * across billing, security, and registration components.
8
+ *
9
+ * Subclasses use `run(obs, onSuccess, fallbackError)` to wire an Observable
10
+ * into the shared state automatically.
11
+ */
12
+ export abstract class BaseAsync {
13
+ readonly loading = signal(false);
14
+ readonly errorMessage = signal('');
15
+ readonly successMessage = signal('');
16
+
17
+ /** Clear any prior messages. */
18
+ protected clearMessages(): void {
19
+ this.errorMessage.set('');
20
+ this.successMessage.set('');
21
+ }
22
+
23
+ /** Set errorMessage from an HTTP error object; falls back to `fallback`. */
24
+ protected setError(err: any, fallback: string): void {
25
+ this.errorMessage.set(err?.error?.message ?? fallback);
26
+ this.loading.set(false);
27
+ }
28
+
29
+ /**
30
+ * Subscribe to an Observable with shared loading/error semantics.
31
+ * Clears messages, sets loading, invokes onSuccess on next, and routes
32
+ * errors through setError with the given fallback message.
33
+ */
34
+ protected run<T>(
35
+ obs: Observable<T>,
36
+ onSuccess: (value: T) => void,
37
+ fallbackError = 'Operation failed. Please try again.',
38
+ ): void {
39
+ this.clearMessages();
40
+ this.loading.set(true);
41
+ obs.subscribe({
42
+ next: (value) => {
43
+ this.loading.set(false);
44
+ onSuccess(value);
45
+ },
46
+ error: (err) => this.setError(err, fallbackError),
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ import { BaseTable } from "./base_table";
2
+
3
+ export abstract class BaseForm extends BaseTable {
4
+ editableRecord: {[key: string]: any} = {};
5
+ isNew = false;
6
+ title = '';
7
+
8
+ override isReadOnly(fieldName: string): boolean {
9
+ return !this.isNew && this.isKey(fieldName);
10
+ }
11
+ }
@@ -0,0 +1,320 @@
1
+ import { inject } from "@angular/core";
2
+ import { ForeignKey, TableColumn, TableDefinition } from "../../model/appdata";
3
+ import { ConstantValue } from "../../model/common";
4
+ import { BaseAuthService } from "../../service/auth.service";
5
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
6
+
7
+
8
+ export abstract class BaseTable {
9
+ protected readonly cacheService = inject(BaseAuthService);
10
+ protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
11
+ protected readonly dialogWidth: string = '400px';
12
+ tableName = '';
13
+ caption = '';
14
+ fieldOptions: {[key: string]: ConstantValue[]} = {};
15
+ fieldLabels: {[key: string]: string} = {};
16
+ isReadOnlyBound = this.isReadOnly.bind(this);
17
+
18
+ isReadOnly(_fieldName: string): boolean {
19
+ return false;
20
+ }
21
+
22
+ titleCase(str: string): string {
23
+ return str.replaceAll('_', ' ').toLowerCase().split(' ').map((word: string) => {
24
+ return word.charAt(0).toUpperCase() + word.slice(1);
25
+ }).join(' ');
26
+ }
27
+
28
+ getCaption() {
29
+ if (!this.caption) {
30
+ this.caption = this.titleCase(this.tableName);
31
+ }
32
+ return this.caption;
33
+ }
34
+
35
+ colCaption(fieldName: string): string {
36
+ if (!this.fieldLabels[fieldName]) {
37
+ const col = this.getColumn(fieldName);
38
+ this.fieldLabels[fieldName] = col?.Caption || this.titleCase(fieldName);
39
+ }
40
+ return this.fieldLabels[fieldName];
41
+ }
42
+
43
+ canRead() {
44
+ return this.cacheService.canRead(this.tableName);
45
+ }
46
+
47
+ canCreate() {
48
+ return this.cacheService.canCreate(this.tableName);
49
+ }
50
+
51
+ canUpdate() {
52
+ return this.cacheService.canUpdate(this.tableName);
53
+ }
54
+
55
+ canDelete() {
56
+ return this.cacheService.canDelete(this.tableName);
57
+ }
58
+
59
+ emptyRecord(): any {
60
+ const record : any = {[this.config.opField]: 'I'}
61
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
62
+ const tableOverride = this.config.tableOverrides?.[this.tableName];
63
+ const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
64
+
65
+ if (tableDef) {
66
+ if (tableDef.Columns) {
67
+ tableDef.Columns.forEach((field) => {
68
+ if (field.SequenceName) {
69
+ return; // auto-generated by database sequence
70
+ }
71
+ if (field.HasDefault && field.DataType === 'timestamp'
72
+ && !editableTimestamps.has(field.PascalName)) {
73
+ return; // audit column — server-managed, don't include
74
+ }
75
+ if (field.HasDefault && field.DataType !== 'timestamp') {
76
+ record[field.PascalName] = field.DataType === 'boolean'
77
+ ? field.DefaultValue === 'true'
78
+ : field.DefaultValue;
79
+ } else {
80
+ record[field.PascalName] = field.DataType === 'boolean' ? false : '';
81
+ }
82
+ });
83
+ }
84
+ if (tableDef.Children) {
85
+ tableDef.Children.forEach((child) => {
86
+ record[child.PascalName] = [];
87
+ });
88
+ }
89
+ // Apply per-table default values from SailGuiConfig.tableOverrides (overrides DB defaults).
90
+ // Values may be literals or zero-arg functions (evaluated now).
91
+ const overrideDefaults = tableOverride?.defaultValues;
92
+ if (overrideDefaults) {
93
+ for (const [key, value] of Object.entries(overrideDefaults)) {
94
+ record[key] = typeof value === 'function' ? (value as () => unknown)() : value;
95
+ }
96
+ }
97
+ }
98
+ return record;
99
+ }
100
+
101
+ getDisplayedColumns(records: any[] = []): string[] {
102
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
103
+ const tableOverride = this.config.tableOverrides?.[this.tableName];
104
+ const hidden = new Set([
105
+ ...this.config.hiddenFields,
106
+ ...(tableOverride?.hiddenFields ?? []),
107
+ ]);
108
+ const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
109
+ if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
110
+ return tableDef.Columns
111
+ .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
112
+ && !editableTimestamps.has(col.PascalName)))
113
+ .sort((a, b) => a.Order - b.Order)
114
+ .map((field) => field.PascalName)
115
+ .filter(name => !hidden.has(name));
116
+ } else if (records.length > 0) {
117
+ return Object.keys(records[0]).filter(name => !hidden.has(name));
118
+ }
119
+ return [];
120
+ }
121
+
122
+ getColumn(fieldName: string): TableColumn | undefined {
123
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
124
+ if (tableDef && tableDef.Columns) {
125
+ return tableDef.Columns.find((field) => field.PascalName === fieldName);
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ displayValue(fieldName: string, rawValue: any): string {
131
+ if (rawValue === null || rawValue === undefined || rawValue === '') return '';
132
+ const col = this.getColumn(fieldName);
133
+ if (!col) return String(rawValue);
134
+ if (col.LookupDomain) {
135
+ const options = this.cacheService.getDomainValues(col.LookupDomain);
136
+ const match = options?.find(o => o.Value === String(rawValue));
137
+ if (match) return match.Caption ?? String(rawValue);
138
+ }
139
+ if (col.LookupTable && col.LookupStyle === 'D') {
140
+ const options = this.cacheService.getTableValues(col.LookupTable);
141
+ const match = options?.find(o => o.Value === String(rawValue));
142
+ if (match) return match.Caption ?? String(rawValue);
143
+ }
144
+ return String(rawValue);
145
+ }
146
+
147
+ isTimeStamp(fieldName: string): boolean {
148
+ const col = this.getColumn(fieldName)
149
+ if (col) {
150
+ return col.DataType === 'timestamp';
151
+ }
152
+ return fieldName.endsWith('Time') || fieldName.endsWith('time');
153
+ }
154
+
155
+ isNumber(fieldName: string): boolean {
156
+ const col = this.getColumn(fieldName)
157
+ if (col) {
158
+ return col.DataType === 'integer' || col.DataType === 'float';
159
+ }
160
+ return false;
161
+ }
162
+
163
+ isBoolean(fieldName: string): boolean {
164
+ const col = this.getColumn(fieldName)
165
+ if (col) {
166
+ return col.DataType === 'boolean' || col.LookupDomain === 'BOOLEAN';
167
+ }
168
+ return false;
169
+ }
170
+
171
+ isKey(fieldName: string): boolean {
172
+ const col = this.getColumn(fieldName)
173
+ if (col) {
174
+ return (col.IsKey);
175
+ }
176
+ return false;
177
+ }
178
+
179
+ isArray(value: any): boolean {
180
+ return Array.isArray(value);
181
+ }
182
+
183
+ formatTimeStamp(val: string): string {
184
+ if (val && typeof val === 'string') {
185
+ return val.slice(0, 16);
186
+ }
187
+ return val;
188
+ }
189
+
190
+ formatRecordTimeStamp(record: any) {
191
+ if (record) {
192
+ for (const key of Object.keys(record)) {
193
+ if (this.isTimeStamp(key)) {
194
+ record[key] = this.formatTimeStamp(record[key]);
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ restoreTimeStamp(val: any): any {
201
+ // PostgreSQL can't cast '' to TIMESTAMP — normalize empty to null.
202
+ if (val === '' || val === undefined || val === null) return null;
203
+ if (typeof val === 'string' && !val.endsWith('Z')) {
204
+ return val + ':00Z';
205
+ }
206
+ return val;
207
+ }
208
+
209
+ restoreRecordTimeStamp(record: any) {
210
+ if (record) {
211
+ for (const key of Object.keys(record)) {
212
+ if (this.isTimeStamp(key)) {
213
+ record[key] = this.restoreTimeStamp(record[key]);
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ readyToSave(record: any, original?: any) {
220
+ this.restoreRecordTimeStamp(record);
221
+ const op = record[this.config.opField];
222
+ if (op === 'I' || op === 'D') return; // already marked
223
+ // Original snapshot may carry display-formatted timestamps; restore both
224
+ // sides to the same wire format before comparing, otherwise scalar
225
+ // fields like Passdate look "modified" purely due to formatting.
226
+ if (original) {
227
+ const normalized = { ...original };
228
+ this.restoreRecordTimeStamp(normalized);
229
+ if (!this.isRecordDirty(record, normalized)) {
230
+ record[this.config.opField] = 'R';
231
+ return;
232
+ }
233
+ }
234
+ record[this.config.opField] = 'U';
235
+ }
236
+
237
+ /** Shallow compare of own scalar fields, ignoring child arrays and op_code. */
238
+ isRecordDirty(current: any, original: any): boolean {
239
+ const opField = this.config.opField;
240
+ for (const key of Object.keys(current)) {
241
+ if (key === opField) continue;
242
+ const curVal = current[key];
243
+ // Ignore child-relation arrays; those have their own op_code per row.
244
+ if (Array.isArray(curVal)) continue;
245
+ if (curVal !== original[key]) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ getDialogData(record: any, isNew: boolean, readOnlyColumns: string[] = []): any {
251
+ return {record, tableName: this.tableName, isNew, readOnlyColumns};
252
+ }
253
+
254
+ getTableName(fieldName: string): string {
255
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
256
+ if (!tableDef) return '';
257
+ const fk = tableDef.Children?.find((c) => c.PascalName === fieldName);
258
+ return fk ? fk.ChildTable : '';
259
+ }
260
+
261
+ getOptions(fieldName: string): ConstantValue[] | undefined {
262
+ if (this.fieldOptions[fieldName]) {
263
+ return this.fieldOptions[fieldName];
264
+ }
265
+ const col = this.getColumn(fieldName)
266
+ if (col && col.LookupDomain) {
267
+ return this.cacheService.getDomainValues(col.LookupDomain);
268
+ }
269
+ if (col && col.LookupTable && col.LookupStyle === 'D') {
270
+ return this.cacheService.getTableValues(col.LookupTable);
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ getKeyFilters(record: any): {[key: string]: string} | undefined {
276
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
277
+ if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) {
278
+ return undefined;
279
+ }
280
+ const filters: {[key: string]: string} = {};
281
+ for (const key of tableDef.Keys) {
282
+ filters[key.PascalName] = record[key.PascalName];
283
+ }
284
+ return filters;
285
+ }
286
+
287
+ getForeignKeyConfig(parentTable: string): {fk: ForeignKey, parent: TableDefinition} | null{
288
+ if (!parentTable) return null;
289
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
290
+ const parent = this.cacheService.getTableDefinition(parentTable);
291
+ if (!tableDef || !tableDef.Keys || !tableDef.Parents || !parent) return null;
292
+ const fk = tableDef.Parents.find((parent) => parent.ParentTable === parentTable);
293
+ if (!fk) return null;
294
+ return {'fk': fk, 'parent': parent};
295
+ }
296
+
297
+ initializeForeignKeys(record: any, parentTable: string, parentRecord: any) {
298
+ const readOnlyColumns: string[] = [];
299
+ const fkData = this.getForeignKeyConfig(parentTable);
300
+ if (fkData && fkData.fk && fkData.parent) {
301
+ fkData.fk.Columns.forEach((column, index) => {
302
+ record[column.PascalName] = parentRecord[fkData.parent.Keys[index].PascalName];
303
+ readOnlyColumns.push(column.PascalName);
304
+ });
305
+ }
306
+ }
307
+
308
+ buildSearchTerms(record: {[key: string]: any}): {[key: string]: string} {
309
+ const hiddenSet = new Set(this.config.hiddenFields);
310
+ const searchTerms: {[key: string]: string} = {};
311
+ for (const key of Object.keys(record)) {
312
+ if (hiddenSet.has(key)) continue;
313
+ const val = record[key];
314
+ if (val !== '' && val !== null && val !== undefined && !Array.isArray(val)) {
315
+ searchTerms[key] = val;
316
+ }
317
+ }
318
+ return searchTerms;
319
+ }
320
+ }
@@ -0,0 +1,93 @@
1
+ import { Directive, inject, Input } from "@angular/core";
2
+ import { BaseTable } from "./base_table";
3
+ import { MatDialog } from "@angular/material/dialog";
4
+
5
+ @Directive()
6
+ export abstract class BaseView extends BaseTable {
7
+ protected readonly dialog = inject(MatDialog);
8
+ @Input() override tableName = '';
9
+ @Input() apiName = '';
10
+ @Input() dialogComponent: any;
11
+ records: Array<{[key: string]: any}> = [];
12
+ displayedColumns: string[] = [];
13
+ searchTerms: {[key: string]: string} = {};
14
+ protected hasLinkPermission = false;
15
+
16
+ protected abstract handleDelete(record: {[key: string]: any}): void;
17
+
18
+ protected abstract handleUpdate(result: {[key: string]: any}, isNew: boolean, original: {[key: string]: any}): void;
19
+
20
+ updateDisplayedColumns(record: Array<{[key: string]: any}>) {
21
+ this.displayedColumns = this.getDisplayedColumns(record);
22
+ if (this.displayedColumns.length > 0 &&
23
+ !this.displayedColumns.includes('actions') &&
24
+ (this.canUpdate() || this.canDelete())
25
+ ) this.displayedColumns.push('actions');
26
+ this.hasLinkPermission = this.canUpdate() || this.canRead();
27
+ }
28
+
29
+ isLinkColumn(column: string): boolean {
30
+ return (this.displayedColumns.length > 0 && this.displayedColumns[0] === column && this.hasLinkPermission);
31
+ }
32
+
33
+ trackByRecord(index: number, record: {[key: string]: any}): any {
34
+ const tableDef = this.cacheService.getTableDefinition(this.tableName);
35
+ if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) return index;
36
+ return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
37
+ }
38
+
39
+ addRecord() {
40
+ if (!this.canCreate()) {
41
+ alert('Missing authorization to create records');
42
+ return;
43
+ }
44
+ const record = this.emptyRecord();
45
+
46
+ if (Object.keys(record).length === 1 && this.records.length > 0) {
47
+ Object.keys(this.records[0]).forEach((key) => {
48
+ record[key] = '';
49
+ });
50
+ }
51
+
52
+ const dialogRef = this.dialog.open(this.dialogComponent, {
53
+ width: '90vw',
54
+ maxWidth: '1200px',
55
+ maxHeight: '90vh',
56
+ disableClose: true,
57
+ data: this.getDialogData(record, true),
58
+ });
59
+
60
+ dialogRef.afterClosed().subscribe((result) => {
61
+ if (result) this.handleUpdate(result, true, {});
62
+ });
63
+ }
64
+
65
+ editRecord(record: {[key: string]: any}) {
66
+ if (!this.canUpdate()) {
67
+ alert('Missing authorization to update records');
68
+ return;
69
+ }
70
+ const dialogData = this.getDialogData(record, false);
71
+ if (this.apiName) {
72
+ dialogData['apiName'] = this.apiName;
73
+ }
74
+ const dialogRef = this.dialog.open(this.dialogComponent, {
75
+ width: '90vw',
76
+ maxWidth: '1200px',
77
+ maxHeight: '90vh',
78
+ disableClose: true,
79
+ data: dialogData,
80
+ });
81
+ dialogRef.afterClosed().subscribe((result) => {
82
+ if (result) this.handleUpdate(result, false, record);
83
+ });
84
+ }
85
+
86
+ deleteRecord(record: {[key: string]: any}) {
87
+ if (!this.canDelete()) {
88
+ alert('Missing authorization to delete records');
89
+ return;
90
+ }
91
+ this.handleDelete(record);
92
+ }
93
+ }
@@ -0,0 +1,10 @@
1
+ <div class="checkout-button">
2
+ <button mat-raised-button class="primary" type="button"
3
+ [disabled]="loading()"
4
+ (click)="startCheckout()">
5
+ @if (loading()) { Starting checkout… } @else { {{ label() }} }
6
+ </button>
7
+ @if (errorMessage()) {
8
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
9
+ }
10
+ </div>
@@ -0,0 +1,57 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { BaseAsync } from '../abstract/base_async';
4
+ import { BillingService } from '../../service/billing.service';
5
+ import { CheckoutMode } from '../../model/appdata';
6
+
7
+ /**
8
+ * Button that opens a provider-hosted checkout session and redirects the
9
+ * browser to the returned URL. Uses BaseAsync for loading/error state.
10
+ *
11
+ * The keel backend validates `priceId`, `successUrl`, and `cancelUrl`
12
+ * against server-side allowlists. Any value outside the allowlist is
13
+ * rejected with 400.
14
+ */
15
+ @Component({
16
+ selector: 'app-checkout-button',
17
+ templateUrl: './checkout_button.html',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ encapsulation: ViewEncapsulation.None,
20
+ imports: [MatButtonModule],
21
+ })
22
+ export class CheckoutButtonComponent extends BaseAsync {
23
+ private readonly billing = inject(BillingService);
24
+
25
+ /** Stripe price ID (or keel-internal plan id resolved server-side). */
26
+ readonly priceId = input.required<string>();
27
+ /** Checkout mode — 'subscription' (default), 'payment' (one-off), or 'setup' (capture method). */
28
+ readonly mode = input<CheckoutMode>('subscription');
29
+ /** Quantity for the priced item. */
30
+ readonly quantity = input<number | undefined>(undefined);
31
+ /** Pre-filled email passed to the provider. */
32
+ readonly email = input<string | undefined>(undefined);
33
+ /** Redirect URL after success — must match keel AllowedRedirectHosts. */
34
+ readonly successUrl = input.required<string>();
35
+ /** Redirect URL after cancel — must match keel AllowedRedirectHosts. */
36
+ readonly cancelUrl = input.required<string>();
37
+ /** Optional metadata bag forwarded to the provider session. */
38
+ readonly metadata = input<{ [key: string]: string } | undefined>(undefined);
39
+ /** Button label. */
40
+ readonly label = input('Continue to payment');
41
+
42
+ startCheckout(): void {
43
+ this.run(
44
+ this.billing.createCheckout({
45
+ mode: this.mode(),
46
+ priceId: this.priceId(),
47
+ quantity: this.quantity(),
48
+ email: this.email(),
49
+ successUrl: this.successUrl(),
50
+ cancelUrl: this.cancelUrl(),
51
+ metadata: this.metadata(),
52
+ }),
53
+ (resp) => { window.location.href = resp.checkoutUrl; },
54
+ 'Could not start checkout. Please try again.',
55
+ );
56
+ }
57
+ }
@@ -0,0 +1,27 @@
1
+ <div class="payment-methods">
2
+ <h3 class="payment-methods-title">Payment methods</h3>
3
+
4
+ @if (loading()) {
5
+ <p class="empty-state">Loading…</p>
6
+ } @else if (methods().length > 0) {
7
+ <ul class="payment-methods-list">
8
+ @for (method of methods(); track method.id) {
9
+ <li class="payment-method">
10
+ <span class="payment-method-label">
11
+ {{ method.provider }} · {{ method.methodType }}
12
+ @if (method.last4) { · •••• {{ method.last4 }} }
13
+ </span>
14
+ @if (method.isDefault) {
15
+ <span class="payment-default-badge">Default</span>
16
+ }
17
+ </li>
18
+ }
19
+ </ul>
20
+ } @else {
21
+ <p class="empty-state">No payment methods on file.</p>
22
+ }
23
+
24
+ @if (errorMessage()) {
25
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
26
+ }
27
+ </div>
@@ -0,0 +1,36 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { MatIconModule } from '@angular/material/icon';
4
+ import { BaseAsync } from '../abstract/base_async';
5
+ import { BillingService } from '../../service/billing.service';
6
+ import { PaymentMethod } from '../../model/appdata';
7
+
8
+ /**
9
+ * Lists the partner's saved payment methods. Inherits loading/error state
10
+ * from BaseAsyncView. The "Manage in Stripe" button is a placeholder — pass
11
+ * `[portalUrl]` to show it; otherwise the button is hidden.
12
+ */
13
+ @Component({
14
+ selector: 'app-payment-methods',
15
+ templateUrl: './payment_methods.html',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ encapsulation: ViewEncapsulation.None,
18
+ imports: [MatButtonModule, MatIconModule],
19
+ })
20
+ export class PaymentMethodsComponent extends BaseAsync implements OnInit {
21
+ private readonly billing = inject(BillingService);
22
+
23
+ readonly methods = signal<PaymentMethod[]>([]);
24
+
25
+ ngOnInit(): void {
26
+ this.load();
27
+ }
28
+
29
+ load(): void {
30
+ this.run(
31
+ this.billing.listPaymentMethods(),
32
+ (list) => this.methods.set(list ?? []),
33
+ 'Failed to load payment methods.',
34
+ );
35
+ }
36
+ }