Skip to content

Commit a5a7d9b

Browse files
committed
feat: add sysadmin row actions and dashboard category granularity
1 parent fb3c535 commit a5a7d9b

7 files changed

Lines changed: 280 additions & 2 deletions

File tree

examples/app-crm/src/dashboards/pipeline.dashboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const PipelineDashboard: Dashboard = {
8787
object: 'crm_opportunity',
8888
aggregate: 'count',
8989
categoryField: 'close_date',
90+
categoryGranularity: 'day',
9091
filter: {
9192
close_date: { $gte: '{90_days_ago}', $lte: '{today}' },
9293
},

packages/platform-objects/src/identity/sys-account.object.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,31 @@ export const SysAccount = ObjectSchema.create({
2121
titleFormat: '{provider_id} - {account_id}',
2222
compactLayout: ['provider_id', 'user_id', 'account_id'],
2323

24+
// Custom actions — sysadmins routinely need to revoke a user's OAuth
25+
// link (e.g. when an SSO provider is decommissioned or the user
26+
// requests it). Better-auth exposes `/unlink-account { providerId,
27+
// accountId }` for this. The form is locked to the row's values so
28+
// it acts as a one-click confirmation rather than a free-form edit.
29+
actions: [
30+
{
31+
name: 'unlink_account',
32+
label: 'Unlink Account',
33+
icon: 'unlink',
34+
variant: 'danger',
35+
mode: 'delete',
36+
locations: ['list_item', 'record_header'],
37+
type: 'api',
38+
target: '/api/v1/auth/unlink-account',
39+
confirmText: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.',
40+
successMessage: 'Identity link removed',
41+
refreshAfter: true,
42+
params: [
43+
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
44+
{ name: 'accountId', field: 'account_id', defaultFromRow: true, required: true },
45+
],
46+
},
47+
],
48+
2449
listViews: {
2550
mine: {
2651
type: 'grid',

packages/platform-objects/src/identity/sys-oauth-application.object.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,57 @@ export const SysOauthApplication = ObjectSchema.create({
2929
titleFormat: '{name}',
3030
compactLayout: ['name', 'client_id', 'type', 'disabled'],
3131

32+
// Custom actions — all OAuth-application mutations are routed through
33+
// better-auth's `@better-auth/oauth-provider` endpoints rather than the
34+
// generic data layer, so server-side validation, secret hashing, and
35+
// audit hooks all run. The generic `delete` API method is intentionally
36+
// dropped from `apiMethods` below (see `enable.apiMethods`) so the only
37+
// delete path is the better-auth wrapper below.
38+
//
39+
// Upstream gap (better-auth 1.6.11): the `/admin/oauth2/update-client`
40+
// endpoint does NOT accept the `disabled` flag in its `update` body
41+
// schema, and no dedicated enable/disable endpoint exists. Until that
42+
// ships upstream, sysadmins cannot toggle `sys_oauth_application.disabled`
43+
// through the UI — `delete` is the only kill-switch. See
44+
// https://github.com/better-auth/better-auth — track the
45+
// `@better-auth/oauth-provider` plugin's adminUpdateOAuthClient schema.
46+
actions: [
47+
{
48+
name: 'rotate_client_secret',
49+
label: 'Rotate Client Secret',
50+
icon: 'refresh-cw',
51+
variant: 'secondary',
52+
mode: 'custom',
53+
locations: ['list_item', 'record_header'],
54+
type: 'api',
55+
method: 'POST',
56+
target: '/api/v1/auth/oauth2/client/rotate-secret',
57+
confirmText: 'Rotate this OAuth client\'s secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.',
58+
successMessage: 'Client secret rotated — copy the new value from the response now.',
59+
refreshAfter: true,
60+
params: [
61+
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
62+
],
63+
},
64+
{
65+
name: 'delete_oauth_application',
66+
label: 'Delete OAuth Application',
67+
icon: 'trash-2',
68+
variant: 'danger',
69+
mode: 'delete',
70+
locations: ['list_item', 'record_header'],
71+
type: 'api',
72+
method: 'POST',
73+
target: '/api/v1/auth/oauth2/delete-client',
74+
confirmText: 'Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.',
75+
successMessage: 'OAuth application deleted',
76+
refreshAfter: true,
77+
params: [
78+
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
79+
],
80+
},
81+
],
82+
3283
listViews: {
3384
active: {
3485
type: 'grid',
@@ -292,7 +343,12 @@ export const SysOauthApplication = ObjectSchema.create({
292343
trackHistory: true,
293344
searchable: true,
294345
apiEnabled: true,
295-
apiMethods: ['get', 'list', 'delete'],
346+
// All mutations (create/update/delete) must go through better-auth's
347+
// oauth-provider endpoints under /api/v1/auth/{admin/,}oauth2/* — the
348+
// generic data layer is read-only for this object so sysadmins cannot
349+
// bypass server-side OAuth validation. The Delete row action above is
350+
// wired to /api/v1/auth/oauth2/delete-client.
351+
apiMethods: ['get', 'list'],
296352
trash: false,
297353
mru: false,
298354
},

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
SysApiKey,
77
SysInvitation,
88
SysMember,
9+
SysOauthApplication,
910
SysOrganization,
1011
SysSession,
1112
SysTeam,
@@ -133,6 +134,48 @@ describe('@objectstack/platform-objects', () => {
133134
});
134135
});
135136

137+
describe('sysadmin row actions', () => {
138+
// Setup-App admins must be able to drive the access-control lifecycle
139+
// without dropping to SQL. These assertions lock in the high-traffic
140+
// affordances (activate/deactivate/clone for RBAC objects; unlink
141+
// for identity links) so they cannot silently regress.
142+
it('SysRole exposes activate/deactivate/clone/set-default row actions', () => {
143+
const names = (SysRole.actions ?? []).map((a) => a.name).sort();
144+
expect(names).toEqual(['activate_role', 'clone_role', 'deactivate_role', 'set_default_role']);
145+
});
146+
147+
it('SysPermissionSet exposes activate/deactivate/clone row actions', () => {
148+
const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort();
149+
expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']);
150+
});
151+
152+
it('SysAccount exposes an unlink-account row action wired to better-auth', () => {
153+
const unlink = (SysAccount.actions ?? []).find((a) => a.name === 'unlink_account');
154+
expect(unlink).toBeDefined();
155+
expect(unlink?.target).toBe('/api/v1/auth/unlink-account');
156+
const paramNames = (unlink?.params ?? []).map((p) => p.name);
157+
expect(paramNames).toEqual(['providerId', 'accountId']);
158+
});
159+
160+
it('SysOauthApplication routes all mutations through better-auth, not the data layer', () => {
161+
const actions = SysOauthApplication.actions ?? [];
162+
const rotate = actions.find((a) => a.name === 'rotate_client_secret');
163+
const del = actions.find((a) => a.name === 'delete_oauth_application');
164+
expect(rotate?.target).toBe('/api/v1/auth/oauth2/client/rotate-secret');
165+
expect(rotate?.method).toBe('POST');
166+
expect((rotate?.params ?? []).map((p) => p.field)).toEqual(['client_id']);
167+
expect(del?.target).toBe('/api/v1/auth/oauth2/delete-client');
168+
expect(del?.method).toBe('POST');
169+
expect(del?.mode).toBe('delete');
170+
// Generic CRUD must NOT expose delete — that path is reserved for
171+
// the better-auth-backed action above so OAuth-specific cleanup
172+
// (token revocation, consent invalidation) always runs.
173+
expect(SysOauthApplication.enable?.apiMethods).not.toContain('delete');
174+
expect(SysOauthApplication.enable?.apiMethods).not.toContain('update');
175+
expect(SysOauthApplication.enable?.apiMethods).not.toContain('create');
176+
});
177+
});
178+
136179
describe('SETUP_APP', () => {
137180
it('parses cleanly through AppSchema', () => {
138181
expect(() => AppSchema.parse(SETUP_APP)).not.toThrow();

packages/platform-objects/src/security/sys-permission-set.object.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,65 @@ export const SysPermissionSet = ObjectSchema.create({
2323
titleFormat: '{label}',
2424
compactLayout: ['label', 'name', 'active'],
2525

26+
// Custom actions — permission sets are templates assigned to roles or
27+
// users (via sys_role_permission_set / sys_user_permission_set). The
28+
// sysadmin operations that don't live on the parent-detail tabs are
29+
// lifecycle (activate/deactivate without losing assignments) and
30+
// clone (build a new permset by tweaking an existing one). Both hit
31+
// the generic data CRUD endpoint — managedBy: 'config' permits it.
32+
actions: [
33+
{
34+
name: 'activate_permission_set',
35+
label: 'Activate',
36+
icon: 'circle-check',
37+
variant: 'secondary',
38+
mode: 'custom',
39+
locations: ['list_item', 'record_header'],
40+
type: 'api',
41+
method: 'PATCH',
42+
target: '/api/v1/data/sys_permission_set/{id}',
43+
bodyExtra: { active: true },
44+
successMessage: 'Permission set activated',
45+
refreshAfter: true,
46+
},
47+
{
48+
name: 'deactivate_permission_set',
49+
label: 'Deactivate',
50+
icon: 'circle-off',
51+
variant: 'danger',
52+
mode: 'custom',
53+
locations: ['list_item', 'record_header'],
54+
type: 'api',
55+
method: 'PATCH',
56+
target: '/api/v1/data/sys_permission_set/{id}',
57+
bodyExtra: { active: false },
58+
confirmText: 'Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.',
59+
successMessage: 'Permission set deactivated',
60+
refreshAfter: true,
61+
},
62+
{
63+
name: 'clone_permission_set',
64+
label: 'Clone',
65+
icon: 'copy',
66+
variant: 'secondary',
67+
mode: 'custom',
68+
locations: ['list_item', 'record_header'],
69+
type: 'api',
70+
method: 'POST',
71+
target: '/api/v1/data/sys_permission_set',
72+
bodyExtra: { active: true },
73+
successMessage: 'Permission set cloned',
74+
refreshAfter: true,
75+
params: [
76+
{ name: 'label', label: 'New Display Name', type: 'text', required: true },
77+
{ name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'Unique snake_case machine name' },
78+
{ field: 'description', defaultFromRow: true },
79+
{ field: 'object_permissions', defaultFromRow: true },
80+
{ field: 'field_permissions', defaultFromRow: true },
81+
],
82+
},
83+
],
84+
2685
listViews: {
2786
active: {
2887
type: 'grid',

packages/platform-objects/src/security/sys-role.object.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,84 @@ export const SysRole = ObjectSchema.create({
2222
titleFormat: '{label}',
2323
compactLayout: ['label', 'name', 'active', 'is_default'],
2424

25+
// Custom actions — system roles drive RBAC and are edited rarely but
26+
// require the four high-frequency sysadmin affordances every IdP
27+
// (Salesforce, ServiceNow, Okta) ships: activate/deactivate (lifecycle
28+
// without losing assignments), mark default (auto-assign to new users),
29+
// and clone (template for new roles). All operations hit the generic
30+
// data CRUD endpoint exposed by `apiEnabled` — no custom server route
31+
// required because `managedBy: 'config'` allows direct mutation.
32+
actions: [
33+
{
34+
name: 'activate_role',
35+
label: 'Activate Role',
36+
icon: 'circle-check',
37+
variant: 'secondary',
38+
mode: 'custom',
39+
locations: ['list_item', 'record_header'],
40+
type: 'api',
41+
method: 'PATCH',
42+
target: '/api/v1/data/sys_role/{id}',
43+
bodyExtra: { active: true },
44+
successMessage: 'Role activated',
45+
refreshAfter: true,
46+
},
47+
{
48+
name: 'deactivate_role',
49+
label: 'Deactivate Role',
50+
icon: 'circle-off',
51+
variant: 'danger',
52+
mode: 'custom',
53+
locations: ['list_item', 'record_header'],
54+
type: 'api',
55+
method: 'PATCH',
56+
target: '/api/v1/data/sys_role/{id}',
57+
bodyExtra: { active: false },
58+
confirmText: 'Deactivate this role? Users with the role keep their assignment but the role stops granting permissions until re-activated.',
59+
successMessage: 'Role deactivated',
60+
refreshAfter: true,
61+
},
62+
{
63+
name: 'set_default_role',
64+
label: 'Set as Default',
65+
icon: 'star',
66+
variant: 'secondary',
67+
mode: 'custom',
68+
locations: ['list_item', 'record_header'],
69+
type: 'api',
70+
method: 'PATCH',
71+
target: '/api/v1/data/sys_role/{id}',
72+
bodyExtra: { is_default: true },
73+
confirmText: 'Make this the default role for new users? Existing users are unaffected.',
74+
successMessage: 'Default role updated',
75+
refreshAfter: true,
76+
},
77+
{
78+
// Clone — POST a new sys_role row pre-filled from the source. The
79+
// dialog asks only for the new API name / label so the operator
80+
// can rename atomically; permissions JSON is copied wholesale via
81+
// defaultFromRow.
82+
name: 'clone_role',
83+
label: 'Clone Role',
84+
icon: 'copy',
85+
variant: 'secondary',
86+
mode: 'custom',
87+
locations: ['list_item', 'record_header'],
88+
type: 'api',
89+
method: 'POST',
90+
target: '/api/v1/data/sys_role',
91+
bodyExtra: { is_default: false, active: true },
92+
successMessage: 'Role cloned',
93+
refreshAfter: true,
94+
params: [
95+
{ name: 'label', label: 'New Display Name', type: 'text', required: true },
96+
{ name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'Unique snake_case machine name' },
97+
{ field: 'description', defaultFromRow: true },
98+
{ field: 'permissions', defaultFromRow: true },
99+
],
100+
},
101+
],
102+
25103
listViews: {
26104
active: {
27105
type: 'grid',

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { FilterConditionSchema } from '../data/filter.zod';
5+
import { DateGranularity } from '../data/query.zod';
56
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
67
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
78
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
@@ -170,7 +171,22 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
170171

171172
/** Category Field (X-Axis / Group By) */
172173
categoryField: z.string().optional().describe('Field for grouping (X-Axis)'),
173-
174+
175+
/**
176+
* Date Bucketing Granularity for `categoryField`
177+
*
178+
* When set and `categoryField` references a date/datetime field, the engine
179+
* buckets values into uniform `day` / `week` / `month` / `quarter` / `year`
180+
* periods server-side (PostgreSQL `date_trunc`, MySQL `date_format`, SQLite
181+
* `strftime`, MongoDB `$dateTrunc`; falls back to in-memory ISO-8601
182+
* bucketing otherwise). Without this, raw timestamps are grouped verbatim
183+
* which typically yields one bucket per row — making time-series charts
184+
* appear flat.
185+
*
186+
* Mirrors the `dateGranularity` shape of {@link GroupByNodeSchema}.
187+
*/
188+
categoryGranularity: DateGranularity.optional().describe('Bucket categoryField date values into day/week/month/quarter/year periods'),
189+
174190
/** Value Field (Y-Axis) */
175191
valueField: z.string().optional().describe('Field for values (Y-Axis)'),
176192

0 commit comments

Comments
 (0)