@nauticana/sail 0.6.0 → 0.7.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.
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +43 -1
- package/src/component/payment/user_payment_methods.ts +159 -0
- package/src/component/table/table_detail.html +21 -0
- package/src/component/table/table_detail.ts +30 -0
- package/src/component/table/table_list.html +24 -3
- package/src/component/table/table_list.ts +24 -0
- package/src/component/table/table_search.html +10 -0
- package/src/component/table/table_search.ts +25 -0
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +44 -6
- package/src/service/auth.service.ts +9 -0
- package/src/service/rest_service.ts +12 -0
- package/src/service/rest_url.ts +6 -1
- package/src/service/user_payment_method.service.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"files": [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { inject } from "@angular/core";
|
|
2
|
-
import { ForeignKey, TableColumn, TableDefinition } from "../../model/appdata";
|
|
2
|
+
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
5
5
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
|
|
@@ -56,6 +56,48 @@ export abstract class BaseTable {
|
|
|
56
56
|
return this.cacheService.canDelete(this.tableName);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Returns the table's registered TableAction list — populated by
|
|
61
|
+
* keel's REST engine from the basis `table_action` table. Empty
|
|
62
|
+
* when the table has no custom actions.
|
|
63
|
+
*/
|
|
64
|
+
getActions(recordSpecific?: boolean): TableAction[] {
|
|
65
|
+
const tableDef = this.cacheService.getTableDefinition(this.tableName);
|
|
66
|
+
const actions = tableDef?.Actions ?? [];
|
|
67
|
+
if (recordSpecific === undefined) return [...actions].sort(this.sortActions);
|
|
68
|
+
return actions.filter((a) => a.recordSpecific === recordSpecific).sort(this.sortActions);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private sortActions = (a: TableAction, b: TableAction) =>
|
|
72
|
+
(a.displayOrder ?? 0) - (b.displayOrder ?? 0);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Authorization gate for a TableAction. Mirrors canRead/canCreate
|
|
76
|
+
* etc. — looks up the caller's grant against (authorityObject,
|
|
77
|
+
* authorityCheck) scoped to the table name. Returns true when the
|
|
78
|
+
* action button should render.
|
|
79
|
+
*/
|
|
80
|
+
canExecuteAction(action: TableAction): boolean {
|
|
81
|
+
return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pulls the primary-key column values off a record into a plain
|
|
86
|
+
* object keyed by the columns' PascalName. Used as the request
|
|
87
|
+
* body when a record-specific TableAction fires — the backend
|
|
88
|
+
* handler receives the row's PK exactly the way generic CRUD
|
|
89
|
+
* does for get / delete.
|
|
90
|
+
*/
|
|
91
|
+
primaryKeyValues(record: Record<string, unknown>): Record<string, unknown> {
|
|
92
|
+
const tableDef = this.cacheService.getTableDefinition(this.tableName);
|
|
93
|
+
const pk: Record<string, unknown> = {};
|
|
94
|
+
if (!tableDef?.Keys) return pk;
|
|
95
|
+
for (const key of tableDef.Keys) {
|
|
96
|
+
pk[key.PascalName] = record[key.PascalName];
|
|
97
|
+
}
|
|
98
|
+
return pk;
|
|
99
|
+
}
|
|
100
|
+
|
|
59
101
|
emptyRecord(): any {
|
|
60
102
|
const record : any = {[this.config.opField]: 'I'}
|
|
61
103
|
const tableDef = this.cacheService.getTableDefinition(this.tableName);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { CommonModule } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatCardModule } from '@angular/material/card';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { UserPaymentMethodService } from '../../service/user_payment_method.service';
|
|
8
|
+
import { UserPaymentMethod } from '../../model/appdata';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* End-user saved payment methods.
|
|
12
|
+
*
|
|
13
|
+
* Drop-in screen that lists the caller's cards/wallets, with set-default
|
|
14
|
+
* + delete actions. Inherits loading/error state from BaseAsync.
|
|
15
|
+
*
|
|
16
|
+
* Customise the heading and "add" CTA via inputs; emit (addClicked) so
|
|
17
|
+
* the consumer routes to its own SetupIntent flow (sail does not bundle
|
|
18
|
+
* a SetupIntent UI today — providers vary too much).
|
|
19
|
+
*
|
|
20
|
+
* Selector: <sail-user-payment-methods>
|
|
21
|
+
*/
|
|
22
|
+
@Component({
|
|
23
|
+
selector: 'sail-user-payment-methods',
|
|
24
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
25
|
+
encapsulation: ViewEncapsulation.None,
|
|
26
|
+
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
|
|
27
|
+
template: `
|
|
28
|
+
<div class="user-payment-methods">
|
|
29
|
+
<h2 class="user-payment-methods__title">{{ title }}</h2>
|
|
30
|
+
|
|
31
|
+
@if (showAdd) {
|
|
32
|
+
<button mat-raised-button color="accent"
|
|
33
|
+
class="user-payment-methods__add"
|
|
34
|
+
(click)="addClicked.emit()">
|
|
35
|
+
<mat-icon>add</mat-icon> {{ addLabel }}
|
|
36
|
+
</button>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@if (methods().length === 0 && !loading()) {
|
|
40
|
+
<p class="user-payment-methods__empty">{{ emptyLabel }}</p>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@for (m of methods(); track m.Id) {
|
|
44
|
+
<mat-card class="user-payment-methods__item">
|
|
45
|
+
<mat-card-content>
|
|
46
|
+
<div class="user-payment-methods__row">
|
|
47
|
+
<div class="user-payment-methods__info">
|
|
48
|
+
<mat-icon>{{ iconFor(m) }}</mat-icon>
|
|
49
|
+
<span class="user-payment-methods__brand">{{ m.Brand || m.MethodType }}</span>
|
|
50
|
+
@if (m.LastFour) {
|
|
51
|
+
<span class="user-payment-methods__last4">•••• {{ m.LastFour }}</span>
|
|
52
|
+
}
|
|
53
|
+
@if (m.IsDefault) {
|
|
54
|
+
<span class="user-payment-methods__default">Default</span>
|
|
55
|
+
}
|
|
56
|
+
</div>
|
|
57
|
+
<div class="user-payment-methods__actions">
|
|
58
|
+
@if (!m.IsDefault) {
|
|
59
|
+
<button mat-button (click)="setDefault(m)">Set default</button>
|
|
60
|
+
}
|
|
61
|
+
<button mat-icon-button (click)="remove(m)" aria-label="Delete payment method">
|
|
62
|
+
<mat-icon>delete</mat-icon>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</mat-card-content>
|
|
67
|
+
</mat-card>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@if (errorMsg()) {
|
|
71
|
+
<p class="user-payment-methods__error">{{ errorMsg() }}</p>
|
|
72
|
+
}
|
|
73
|
+
</div>
|
|
74
|
+
`,
|
|
75
|
+
styles: `
|
|
76
|
+
.user-payment-methods { display: flex; flex-direction: column; gap: 12px; }
|
|
77
|
+
.user-payment-methods__title { margin: 0; }
|
|
78
|
+
.user-payment-methods__add { align-self: flex-start; }
|
|
79
|
+
.user-payment-methods__empty { color: var(--mat-app-on-surface-variant, #555); }
|
|
80
|
+
.user-payment-methods__item { background: var(--mat-app-surface, #fff); }
|
|
81
|
+
.user-payment-methods__row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
82
|
+
.user-payment-methods__info { display: flex; align-items: center; gap: 8px; }
|
|
83
|
+
.user-payment-methods__brand { font-weight: 600; }
|
|
84
|
+
.user-payment-methods__last4 { color: var(--mat-app-on-surface-variant, #555); }
|
|
85
|
+
.user-payment-methods__default { color: var(--mat-app-primary, #1976d2); margin-left: 8px; font-size: 12px; font-weight: 600; }
|
|
86
|
+
.user-payment-methods__actions { display: flex; align-items: center; gap: 4px; }
|
|
87
|
+
.user-payment-methods__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0; }
|
|
88
|
+
`,
|
|
89
|
+
})
|
|
90
|
+
export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
91
|
+
@Input() title = 'Payment Methods';
|
|
92
|
+
@Input() addLabel = 'Add Payment Method';
|
|
93
|
+
@Input() emptyLabel = 'No saved payment methods yet.';
|
|
94
|
+
@Input() showAdd = true;
|
|
95
|
+
|
|
96
|
+
/** Emitted when the "Add" button is tapped. Consumer routes to its SetupIntent flow. */
|
|
97
|
+
@Output() addClicked = new EventEmitter<void>();
|
|
98
|
+
/** Emitted after a successful set-default flip. */
|
|
99
|
+
@Output() defaultChanged = new EventEmitter<UserPaymentMethod>();
|
|
100
|
+
/** Emitted after a successful delete. */
|
|
101
|
+
@Output() deleted = new EventEmitter<UserPaymentMethod>();
|
|
102
|
+
|
|
103
|
+
private readonly service = inject(UserPaymentMethodService);
|
|
104
|
+
|
|
105
|
+
readonly methods = signal<UserPaymentMethod[]>([]);
|
|
106
|
+
readonly errorMsg = signal<string>('');
|
|
107
|
+
|
|
108
|
+
ngOnInit(): void {
|
|
109
|
+
this.load();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
load(): void {
|
|
113
|
+
this.run(
|
|
114
|
+
this.service.list(),
|
|
115
|
+
(rows) => {
|
|
116
|
+
// Sort default-first locally — keel's generic CRUD doesn't
|
|
117
|
+
// honour a sort hint without rest_api_header config.
|
|
118
|
+
const sorted = [...(rows ?? [])].sort((a, b) =>
|
|
119
|
+
Number(b.IsDefault) - Number(a.IsDefault),
|
|
120
|
+
);
|
|
121
|
+
this.methods.set(sorted);
|
|
122
|
+
},
|
|
123
|
+
'Failed to load payment methods.',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setDefault(m: UserPaymentMethod): void {
|
|
128
|
+
this.service.setDefault(m.Id).subscribe({
|
|
129
|
+
next: () => {
|
|
130
|
+
this.methods.update((list) =>
|
|
131
|
+
list.map((row) => ({ ...row, IsDefault: row.Id === m.Id })),
|
|
132
|
+
);
|
|
133
|
+
this.defaultChanged.emit(m);
|
|
134
|
+
},
|
|
135
|
+
error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to set default'),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
remove(m: UserPaymentMethod): void {
|
|
140
|
+
this.service.delete(m.Id).subscribe({
|
|
141
|
+
next: () => {
|
|
142
|
+
this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
|
|
143
|
+
this.deleted.emit(m);
|
|
144
|
+
},
|
|
145
|
+
error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to delete'),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
iconFor(m: UserPaymentMethod): string {
|
|
150
|
+
switch (m.MethodType) {
|
|
151
|
+
case 'card': return 'credit_card';
|
|
152
|
+
case 'bank': return 'account_balance';
|
|
153
|
+
case 'apple_pay':
|
|
154
|
+
case 'google_pay':
|
|
155
|
+
case 'wallet': return 'wallet';
|
|
156
|
+
default: return 'credit_card';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
@if (canCreate()) {
|
|
5
5
|
<button mat-raised-button class="accent" (click)="addRecord()">New Record</button>
|
|
6
6
|
}
|
|
7
|
+
@for (action of getActions(false); track action.action) {
|
|
8
|
+
@if (canExecuteAction(action)) {
|
|
9
|
+
<button mat-stroked-button type="button"
|
|
10
|
+
[title]="action.caption"
|
|
11
|
+
(click)="executeAction(action)">
|
|
12
|
+
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
|
|
13
|
+
{{ action.caption }}
|
|
14
|
+
</button>
|
|
15
|
+
}
|
|
16
|
+
}
|
|
7
17
|
</div>
|
|
8
18
|
<table class="mat-table">
|
|
9
19
|
<thead>
|
|
@@ -44,6 +54,17 @@
|
|
|
44
54
|
} @else if (canDelete()) {
|
|
45
55
|
<button mat-icon-button (click)="deleteRow(record)" title="Delete"><mat-icon>delete</mat-icon></button>
|
|
46
56
|
}
|
|
57
|
+
@if (record[config.opField] !== 'I') {
|
|
58
|
+
@for (action of getActions(true); track action.action) {
|
|
59
|
+
@if (canExecuteAction(action)) {
|
|
60
|
+
<button mat-icon-button type="button"
|
|
61
|
+
[title]="action.caption"
|
|
62
|
+
(click)="executeAction(action, record)">
|
|
63
|
+
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
|
|
64
|
+
</button>
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
47
68
|
</td>
|
|
48
69
|
}
|
|
49
70
|
</tr>
|
|
@@ -5,6 +5,8 @@ import { BaseForm } from "../abstract/base_form";
|
|
|
5
5
|
import { MatDialog } from "@angular/material/dialog";
|
|
6
6
|
import { TableForm } from "../form/form_table";
|
|
7
7
|
import { MatIconModule } from "@angular/material/icon";
|
|
8
|
+
import { BackendService } from "../../service/rest_service";
|
|
9
|
+
import { TableAction } from "../../model/appdata";
|
|
8
10
|
|
|
9
11
|
@Component({
|
|
10
12
|
selector: 'table-detail',
|
|
@@ -31,6 +33,34 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
31
33
|
fkColumns: string[] = [];
|
|
32
34
|
|
|
33
35
|
private readonly dialog = inject(MatDialog);
|
|
36
|
+
private readonly backendService = inject(BackendService);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fire a TableAction. Per-row actions require the record to be
|
|
40
|
+
* server-side (not 'I' inserted-locally-not-saved); table-level
|
|
41
|
+
* actions only need the parent to be writable. Action POSTs hit
|
|
42
|
+
* the resolved URL directly — no need to round-trip through the
|
|
43
|
+
* parent form.
|
|
44
|
+
*/
|
|
45
|
+
executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
|
|
46
|
+
if (!this.canExecuteAction(action)) {
|
|
47
|
+
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (action.recordSpecific && record && record[this.config.opField] === 'I') {
|
|
51
|
+
alert('Save the record first before running this action.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
55
|
+
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
56
|
+
this.backendService.executeAction(action.method, body).subscribe({
|
|
57
|
+
next: () => {/* parent form re-fetches on its own save */},
|
|
58
|
+
error: (err) => {
|
|
59
|
+
console.error(`Action ${action.action} failed`, err);
|
|
60
|
+
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
34
64
|
|
|
35
65
|
override canCreate() { return !this.parentReadOnly() && super.canCreate(); }
|
|
36
66
|
override canUpdate() { return !this.parentReadOnly() && super.canUpdate(); }
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
<h2>{{ getCaption() }}</h2>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<div class="table-list__toolbar">
|
|
4
|
+
@if (canCreate()) {
|
|
5
|
+
<button mat-raised-button class="accent" (click)="addRecord()">New Record</button>
|
|
6
|
+
}
|
|
7
|
+
@for (action of getActions(false); track action.action) {
|
|
8
|
+
@if (canExecuteAction(action)) {
|
|
9
|
+
<button mat-stroked-button type="button"
|
|
10
|
+
[title]="action.caption"
|
|
11
|
+
(click)="executeAction(action)">
|
|
12
|
+
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
|
|
13
|
+
{{ action.caption }}
|
|
14
|
+
</button>
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
</div>
|
|
6
18
|
|
|
7
19
|
<table>
|
|
8
20
|
<thead>
|
|
@@ -27,6 +39,15 @@
|
|
|
27
39
|
} @else {
|
|
28
40
|
<button mat-icon-button type="button" (click)="editRecord(record)" title="Edit"><mat-icon>edit</mat-icon></button>
|
|
29
41
|
<button mat-icon-button type="button" (click)="deleteRecord(record)" title="Delete"><mat-icon>delete</mat-icon></button>
|
|
42
|
+
@for (action of getActions(true); track action.action) {
|
|
43
|
+
@if (canExecuteAction(action)) {
|
|
44
|
+
<button mat-icon-button type="button"
|
|
45
|
+
[title]="action.caption"
|
|
46
|
+
(click)="executeAction(action, record)">
|
|
47
|
+
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
|
|
48
|
+
</button>
|
|
49
|
+
}
|
|
50
|
+
}
|
|
30
51
|
}
|
|
31
52
|
</td>
|
|
32
53
|
}
|
|
@@ -4,6 +4,7 @@ import { ActivatedRoute, Params } from "@angular/router";
|
|
|
4
4
|
import { BaseView } from "../abstract/base_view";
|
|
5
5
|
import { TableEdit } from "./table_edit";
|
|
6
6
|
import { BackendService } from "../../service/rest_service";
|
|
7
|
+
import { TableAction } from "../../model/appdata";
|
|
7
8
|
import { MatButtonModule } from "@angular/material/button";
|
|
8
9
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
9
10
|
import { MatIconModule } from "@angular/material/icon";
|
|
@@ -77,6 +78,29 @@ export class TableList extends BaseView implements OnInit {
|
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Fire a TableAction. For record-specific actions, `record` carries
|
|
83
|
+
* the row's primary key columns (lifted via BaseTable.primaryKeyValues).
|
|
84
|
+
* For table-level actions, `record` is undefined and the body is empty.
|
|
85
|
+
* Refreshes the row list on success — most actions mutate visible
|
|
86
|
+
* state (set_default flipping defaults, etc.).
|
|
87
|
+
*/
|
|
88
|
+
executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
|
|
89
|
+
if (!this.canExecuteAction(action)) {
|
|
90
|
+
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
94
|
+
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
95
|
+
this.backendService.executeAction(action.method, body).subscribe({
|
|
96
|
+
next: () => this.fetchRecords(),
|
|
97
|
+
error: (err) => {
|
|
98
|
+
console.error(`Action ${action.action} failed`, err);
|
|
99
|
+
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
handleQueryParams(params: Params) {
|
|
81
105
|
const newSearchTerms: {[key: string]: string} = {};
|
|
82
106
|
let hasSearchTerms = false;
|
|
@@ -14,4 +14,14 @@
|
|
|
14
14
|
@if (canCreate()) {
|
|
15
15
|
<button mat-raised-button class="accent" (click)="onAddRecord()">New Record</button>
|
|
16
16
|
}
|
|
17
|
+
@for (action of getActions(false); track action.action) {
|
|
18
|
+
@if (canExecuteAction(action)) {
|
|
19
|
+
<button mat-stroked-button type="button"
|
|
20
|
+
[title]="action.caption"
|
|
21
|
+
(click)="executeAction(action)">
|
|
22
|
+
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
|
|
23
|
+
{{ action.caption }}
|
|
24
|
+
</button>
|
|
25
|
+
}
|
|
26
|
+
}
|
|
17
27
|
</div>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
3
4
|
import { ActivatedRoute, Router } from "@angular/router";
|
|
4
5
|
import { BaseForm } from "../abstract/base_form";
|
|
5
6
|
import { RecordForm } from "../form/form_record";
|
|
6
7
|
import { ApplicationMenu } from "../../model/common";
|
|
8
|
+
import { TableAction } from "../../model/appdata";
|
|
9
|
+
import { BackendService } from "../../service/rest_service";
|
|
7
10
|
|
|
8
11
|
@Component({
|
|
9
12
|
selector: 'table-search',
|
|
@@ -12,6 +15,7 @@ import { ApplicationMenu } from "../../model/common";
|
|
|
12
15
|
templateUrl: './table_search.html',
|
|
13
16
|
imports: [
|
|
14
17
|
MatButtonModule,
|
|
18
|
+
MatIconModule,
|
|
15
19
|
RecordForm,
|
|
16
20
|
],
|
|
17
21
|
})
|
|
@@ -24,6 +28,27 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
24
28
|
private readonly router = inject(Router);
|
|
25
29
|
private readonly route = inject(ActivatedRoute);
|
|
26
30
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
31
|
+
private readonly backendService = inject(BackendService);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fire a table-level TableAction from the search-screen toolbar.
|
|
35
|
+
* Record-specific actions never show up here — search has no
|
|
36
|
+
* row context.
|
|
37
|
+
*/
|
|
38
|
+
executeAction(action: TableAction): void {
|
|
39
|
+
if (!this.canExecuteAction(action)) {
|
|
40
|
+
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
44
|
+
this.backendService.executeAction(action.method, {}).subscribe({
|
|
45
|
+
next: () => {/* no-op — search screen has no list to refresh */},
|
|
46
|
+
error: (err) => {
|
|
47
|
+
console.error(`Action ${action.action} failed`, err);
|
|
48
|
+
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
27
52
|
|
|
28
53
|
ngOnInit() {
|
|
29
54
|
const data = this.route.snapshot.data;
|
package/src/index.ts
CHANGED
|
@@ -34,9 +34,11 @@ export * from './component/billing/checkout_button';
|
|
|
34
34
|
export * from './component/billing/payment_methods';
|
|
35
35
|
export * from './component/payout/provider_onboarding';
|
|
36
36
|
export * from './component/payout/bank_info_form';
|
|
37
|
+
export * from './component/payment/user_payment_methods';
|
|
37
38
|
export * from './service/auth.service';
|
|
38
39
|
export * from './service/billing.service';
|
|
39
40
|
export * from './service/payout.service';
|
|
41
|
+
export * from './service/user_payment_method.service';
|
|
40
42
|
export * from './service/script_loader';
|
|
41
43
|
export * from './service/auth.interceptor';
|
|
42
44
|
export * from './service/api-response.interceptor';
|
package/src/model/appdata.ts
CHANGED
|
@@ -56,13 +56,27 @@ export interface TableColumn {
|
|
|
56
56
|
Validators?: AbstractControlOptions['validators'];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// One custom button registered against a table via the basis
|
|
60
|
+
// table_action seed. Auth gating uses the existing
|
|
61
|
+
// authorization_role_permission path against (authorityObject,
|
|
62
|
+
// authorityCheck, low_limit=tableName) — see canExecute() in
|
|
63
|
+
// BaseAuthService.
|
|
64
|
+
//
|
|
65
|
+
// recordSpecific=true → button renders next to per-row edit/delete.
|
|
66
|
+
// recordSpecific=false → button renders next to the "New Record" toolbar.
|
|
67
|
+
//
|
|
68
|
+
// `method` is the URL POST target the click should hit (resolved by
|
|
69
|
+
// keel from table_name + action_name + optional method_name override).
|
|
59
70
|
export interface TableAction {
|
|
60
|
-
action:
|
|
61
|
-
caption:
|
|
62
|
-
method:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
action: string; // lowercase action_name
|
|
72
|
+
caption: string;
|
|
73
|
+
method: string; // resolved URL path (POST target)
|
|
74
|
+
icon?: string; // Material icon name; falsy → label-only
|
|
75
|
+
recordSpecific: boolean;
|
|
76
|
+
displayOrder?: number;
|
|
77
|
+
confirmMessage?: string;
|
|
78
|
+
authorityObject: string; // uppercased table_name — for canExecute()
|
|
79
|
+
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
export interface ForeignKey {
|
|
@@ -328,3 +342,27 @@ export interface CountryProfile {
|
|
|
328
342
|
taxIdPlaceholder: string;
|
|
329
343
|
taxIdHint: string;
|
|
330
344
|
}
|
|
345
|
+
|
|
346
|
+
// ── User payment methods ──
|
|
347
|
+
// Consumer-side saved cards/wallets. Mirrors basis user_payment_method
|
|
348
|
+
// one-to-one. Distinct from PaymentMethod (partner-scoped SaaS billing
|
|
349
|
+
// card).
|
|
350
|
+
//
|
|
351
|
+
// PascalCase fields match keel's generic REST CRUD response shape —
|
|
352
|
+
// list and delete go through that path (the table is UserSpecific so
|
|
353
|
+
// the auto-filter scopes reads + owner-locks DELETE). Only set-default
|
|
354
|
+
// has a custom endpoint because it needs atomic multi-row UPDATE.
|
|
355
|
+
|
|
356
|
+
export interface UserPaymentMethod {
|
|
357
|
+
Id: number;
|
|
358
|
+
UserId?: number;
|
|
359
|
+
MethodType: string; // matches basis constant_header 'payment_method_type' — card / bank / wallet / apple_pay / google_pay
|
|
360
|
+
Provider: string; // 'stripe', ...
|
|
361
|
+
LastFour?: string;
|
|
362
|
+
Brand?: string;
|
|
363
|
+
ExpiryMonth?: number;
|
|
364
|
+
ExpiryYear?: number;
|
|
365
|
+
Currency: string;
|
|
366
|
+
IsDefault: boolean;
|
|
367
|
+
CreatedAt: string;
|
|
368
|
+
}
|
|
@@ -471,6 +471,15 @@ export abstract class BaseAuthService {
|
|
|
471
471
|
canCreate(tableName: string) { return this.checkPermission('TABLE', 'INSERT', tableName); }
|
|
472
472
|
canUpdate(tableName: string) { return this.checkPermission('TABLE', 'UPDATE', tableName); }
|
|
473
473
|
canDelete(tableName: string) { return this.checkPermission('TABLE', 'DELETE', tableName); }
|
|
474
|
+
/**
|
|
475
|
+
* Authorization check for a custom TableAction. authorityObject is the
|
|
476
|
+
* uppercased table_name; activity is the uppercased action_name. The
|
|
477
|
+
* grant's low_limit is checked against the table_name (lowercase) —
|
|
478
|
+
* matching the convention populated by keel's loadTableActions.
|
|
479
|
+
*/
|
|
480
|
+
canExecute(authorityObject: string, activity: string, tableName: string): boolean {
|
|
481
|
+
return this.checkPermission(authorityObject, activity, tableName);
|
|
482
|
+
}
|
|
474
483
|
canAccess(pageName: string) { return this.checkPermission('PAGE', 'ACCESS', pageName); }
|
|
475
484
|
canReport(reportName: string) { return this.checkPermission('REPORT', 'ACCESS', reportName); }
|
|
476
485
|
|
|
@@ -91,4 +91,16 @@ export class BackendService {
|
|
|
91
91
|
delete(apiName: string, filter?: {[key: string]: string}): Observable<{message: string}> {
|
|
92
92
|
return this.http.delete<{message: string}>(this.apiUrl(apiName + '/delete'), {params: this.toParams(filter)}).pipe(catchError(this.handleError));
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fire a TableAction button. `actionPath` is the resolved URL path
|
|
97
|
+
* the keel REST engine pre-computed onto TableAction.method
|
|
98
|
+
* (e.g. "/user_payment_method/set_default"); pass it verbatim and
|
|
99
|
+
* the call will POST to {host}{api_prefix}{actionPath} with the
|
|
100
|
+
* supplied body. For record-specific actions the body carries the
|
|
101
|
+
* primary key values; for table-level actions it is typically `{}`.
|
|
102
|
+
*/
|
|
103
|
+
executeAction<T = unknown>(actionPath: string, body: Record<string, unknown> = {}): Observable<T> {
|
|
104
|
+
return this.http.post<T>(this.apiUrl(actionPath), body).pipe(catchError(this.handleError));
|
|
105
|
+
}
|
|
94
106
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -31,11 +31,16 @@ export const RestURL = {
|
|
|
31
31
|
deleteAccountURL: '/api/user/account',
|
|
32
32
|
pushRegisterURL: '/api/push/register',
|
|
33
33
|
pushRevokeURL: '/api/push/revoke',
|
|
34
|
-
// Payout — keel/payout endpoints
|
|
34
|
+
// Payout — keel/payout endpoints.
|
|
35
35
|
payoutOnboardStartURL: '/api/v1/payout/onboard/start',
|
|
36
36
|
payoutReusableURL: '/api/v1/payout/reusable',
|
|
37
37
|
payoutReusableLinkURL: '/api/v1/payout/reusable/link',
|
|
38
38
|
payoutStatusURL: '/api/v1/payout/status',
|
|
39
|
+
// User payment methods — list/delete go through keel's generic REST
|
|
40
|
+
// CRUD against the UserSpecific basis table; set-default is a
|
|
41
|
+
// TableAction (basis table_action row), mounted at the conventional
|
|
42
|
+
// URL POST /api/v1/user_payment_method/set_default.
|
|
43
|
+
paymentMethodSetDefaultURL: '/api/v1/user_payment_method/set_default',
|
|
39
44
|
};
|
|
40
45
|
|
|
41
46
|
/** Call this at app startup to set the backend host URL */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { RestURL } from './rest_url';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
import { UserPaymentMethod } from '../model/appdata';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* UserPaymentMethodService — consumer-side saved-payment-methods API.
|
|
10
|
+
*
|
|
11
|
+
* list and delete go through keel's generic REST CRUD against the
|
|
12
|
+
* basis user_payment_method table — that table is UserSpecific (FK
|
|
13
|
+
* column `user_id`), so the auto-filter scopes reads + owner-locks
|
|
14
|
+
* DELETE without any custom server-side glue. setDefault has a custom
|
|
15
|
+
* endpoint because it needs an atomic multi-row UPDATE
|
|
16
|
+
* (`SET is_default = (id = ?) WHERE user_id = session`) that abstract
|
|
17
|
+
* single-row CRUD can't express.
|
|
18
|
+
*/
|
|
19
|
+
@Injectable({ providedIn: 'root' })
|
|
20
|
+
export class UserPaymentMethodService {
|
|
21
|
+
private readonly http = inject(HttpClient);
|
|
22
|
+
private readonly backend = inject(BackendService);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List the caller's saved payment methods. Returns rows with PascalCase
|
|
26
|
+
* fields per the generic-CRUD convention; the default row sorts first
|
|
27
|
+
* server-side (filter ordering is set on rest_api_header).
|
|
28
|
+
*/
|
|
29
|
+
list(): Observable<UserPaymentMethod[]> {
|
|
30
|
+
return this.backend.list<UserPaymentMethod>('user_payment_method');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Delete a saved payment method. user_id is auto-scoped by keel. */
|
|
34
|
+
delete(id: number): Observable<{ message: string }> {
|
|
35
|
+
return this.backend.delete('user_payment_method', { Id: String(id) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mark one row as default; clears every other row in the same call.
|
|
40
|
+
* Custom endpoint — see service-class doc for the rationale.
|
|
41
|
+
*/
|
|
42
|
+
setDefault(id: number): Observable<{ message: string }> {
|
|
43
|
+
return this.http.post<{ message: string }>(
|
|
44
|
+
RestURL.httpHost + RestURL.paymentMethodSetDefaultURL,
|
|
45
|
+
{ id },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|