@digimuza/system-core 0.1.0 → 0.2.0-rc.2
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/INTEGRATION.md +75 -11
- package/README.md +8 -0
- package/dist/convex/functions.d.ts +28 -3
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +23 -4
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/convex/schema.d.ts +12 -0
- package/dist/convex/schema.d.ts.map +1 -1
- package/dist/convex/schema.js +2 -0
- package/dist/convex/schema.js.map +1 -1
- package/dist/core/access.d.ts +13 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +42 -7
- package/dist/core/access.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/modules-service.d.ts +5 -0
- package/dist/core/modules-service.d.ts.map +1 -1
- package/dist/core/modules-service.js +5 -3
- package/dist/core/modules-service.js.map +1 -1
- package/dist/core/role-view.d.ts +19 -0
- package/dist/core/role-view.d.ts.map +1 -0
- package/dist/core/role-view.js +41 -0
- package/dist/core/role-view.js.map +1 -0
- package/dist/core/store.d.ts +8 -0
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +1 -0
- package/dist/core/store.js.map +1 -1
- package/dist/finance/index.d.ts +118 -0
- package/dist/finance/index.d.ts.map +1 -0
- package/dist/finance/index.js +144 -0
- package/dist/finance/index.js.map +1 -0
- package/dist/react/index.d.ts +7 -4
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +10 -1
- package/dist/react/index.js.map +1 -1
- package/package.json +7 -7
- package/src/convex/functions.ts +28 -5
- package/src/convex/index.ts +1 -1
- package/src/convex/schema.ts +3 -0
- package/src/core/access.ts +41 -8
- package/src/core/index.ts +1 -0
- package/src/core/modules-service.ts +9 -3
- package/src/core/role-view.ts +43 -0
- package/src/core/store.ts +5 -0
- package/src/finance/index.ts +189 -0
- package/src/react/index.tsx +12 -2
package/dist/react/index.d.ts
CHANGED
|
@@ -11,11 +11,8 @@ export type SessionState = ({
|
|
|
11
11
|
} & SessionSummary & {
|
|
12
12
|
actor: NonNullable<SessionSummary["actor"]>;
|
|
13
13
|
});
|
|
14
|
-
type EmptySession = {
|
|
14
|
+
type EmptySession = Omit<SessionSummary, "actor"> & {
|
|
15
15
|
actor: null;
|
|
16
|
-
modules: ModuleState[];
|
|
17
|
-
permissions: string[];
|
|
18
|
-
flags: Record<string, boolean>;
|
|
19
16
|
};
|
|
20
17
|
export interface SystemProviderProps {
|
|
21
18
|
/** Your exported `session` query, e.g. `api.system.session`. */
|
|
@@ -39,6 +36,12 @@ export declare function useCurrentUser(): {
|
|
|
39
36
|
export declare function useVisibleModules(): ModuleState[];
|
|
40
37
|
/** Whether the current actor holds `permission` right now (UI hint). */
|
|
41
38
|
export declare function useCan(permission: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
|
|
41
|
+
* is active (then the rest of the session is that role's). Start and stop with
|
|
42
|
+
* the `startRoleView` / `stopRoleView` mutations.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useRoleView(): Pick<SessionSummary, "canViewAs" | "roleView">;
|
|
42
45
|
export declare function useFeatureFlag(flagId: string): boolean;
|
|
43
46
|
export declare function Can(props: {
|
|
44
47
|
permission: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACtD,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC7E,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEnE,MAAM,MAAM,YAAY,GACpB,CAAC;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CAAC,GACtC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,YAAY,CAAC,GACxC,CAAC;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAA;CAAE,CAAC,CAAA;AAE5F,KAAK,YAAY,GAAG
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACtD,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC7E,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEnE,MAAM,MAAM,YAAY,GACpB,CAAC;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CAAC,GACtC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,YAAY,CAAC,GACxC,CAAC;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAA;CAAE,CAAC,CAAA;AAE5F,KAAK,YAAY,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,GAAG;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,CAAA;AAOnE,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,OAAO,EAAE,iBAAiB,CAAC,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,cAAc,CAAC,CAAA;IAC/G,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,mBAAmB,+BAQxE;AAED,wBAAgB,gBAAgB,IAAI,YAAY,CAI/C;AAED,gEAAgE;AAChE,wBAAgB,cAAc;;;;;;SAG7B;AAED,6CAA6C;AAC7C,wBAAgB,iBAAiB,IAAI,WAAW,EAAE,CAEjD;AAED,wEAAwE;AACxE,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAElD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,UAAU,CAAC,CAG5E;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,+BAE5F;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAE5D;AAED,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,CAAA"}
|
package/dist/react/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
import { useQuery } from "convex/react";
|
|
7
7
|
import { createContext, useContext, useMemo } from "react";
|
|
8
8
|
import { toSystemError } from "../core/errors.js";
|
|
9
|
-
const EMPTY = { actor: null, modules: [], permissions: [], flags: {} };
|
|
9
|
+
const EMPTY = { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null };
|
|
10
10
|
const LOADING = { status: "loading", ...EMPTY };
|
|
11
11
|
const SessionContext = createContext(null);
|
|
12
12
|
/** Subscribes to the session summary; updates live when roles, modules or flags change. */
|
|
@@ -40,6 +40,15 @@ export function useVisibleModules() {
|
|
|
40
40
|
export function useCan(permission) {
|
|
41
41
|
return useSystemSession().permissions.includes(permission);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
|
|
45
|
+
* is active (then the rest of the session is that role's). Start and stop with
|
|
46
|
+
* the `startRoleView` / `stopRoleView` mutations.
|
|
47
|
+
*/
|
|
48
|
+
export function useRoleView() {
|
|
49
|
+
const { canViewAs, roleView } = useSystemSession();
|
|
50
|
+
return { canViewAs, roleView };
|
|
51
|
+
}
|
|
43
52
|
export function useFeatureFlag(flagId) {
|
|
44
53
|
return useSystemSession().flags[flagId] === true;
|
|
45
54
|
}
|
package/dist/react/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AACZ,yEAAyE;AACzE,2EAA2E;AAC3E,gCAAgC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAEvC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,OAAO,EAAkB,MAAM,OAAO,CAAA;AAE1E,OAAO,EAAE,aAAa,EAAoB,MAAM,mBAAmB,CAAA;AASnE,MAAM,KAAK,GAAiB,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digimuza/system-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-rc.2",
|
|
4
4
|
"description": "Shared system core for per-tenant Next.js + Convex applications: config, identity, roles, permissions, invites, API keys, feature flags, logging, tracing and audit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -28,8 +28,7 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"src",
|
|
30
30
|
"README.md",
|
|
31
|
-
"INTEGRATION.md"
|
|
32
|
-
"CHANGELOG.md"
|
|
31
|
+
"INTEGRATION.md"
|
|
33
32
|
],
|
|
34
33
|
"exports": {
|
|
35
34
|
".": {
|
|
@@ -57,6 +56,10 @@
|
|
|
57
56
|
"types": "./dist/react/index.d.ts",
|
|
58
57
|
"default": "./dist/react/index.js"
|
|
59
58
|
},
|
|
59
|
+
"./finance": {
|
|
60
|
+
"types": "./dist/finance/index.d.ts",
|
|
61
|
+
"default": "./dist/finance/index.js"
|
|
62
|
+
},
|
|
60
63
|
"./testing": {
|
|
61
64
|
"types": "./dist/testing/index.d.ts",
|
|
62
65
|
"default": "./dist/testing/index.js"
|
|
@@ -70,9 +73,7 @@
|
|
|
70
73
|
"check:pack": "node scripts/check-pack.mjs",
|
|
71
74
|
"prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build && npm run check:pack",
|
|
72
75
|
"lint": "biome lint",
|
|
73
|
-
"
|
|
74
|
-
"version-packages": "changeset version",
|
|
75
|
-
"release": "changeset publish"
|
|
76
|
+
"release:cut": "node scripts/release-cut.mjs"
|
|
76
77
|
},
|
|
77
78
|
"engines": {
|
|
78
79
|
"node": ">=20"
|
|
@@ -93,7 +94,6 @@
|
|
|
93
94
|
},
|
|
94
95
|
"devDependencies": {
|
|
95
96
|
"@biomejs/biome": "^2.6.6",
|
|
96
|
-
"@changesets/cli": "^3.0.3",
|
|
97
97
|
"@edge-runtime/vm": "^5.0.0",
|
|
98
98
|
"@types/node": "^26.6.6",
|
|
99
99
|
"@types/react": "^19.3.0",
|
package/src/convex/functions.ts
CHANGED
|
@@ -28,13 +28,17 @@ import * as flags from "../core/feature-flags.js"
|
|
|
28
28
|
import * as invites from "../core/invites.js"
|
|
29
29
|
import * as mods from "../core/modules-service.js"
|
|
30
30
|
import { registerCatalog } from "../core/registry.js"
|
|
31
|
+
import * as roleView from "../core/role-view.js"
|
|
31
32
|
import * as roles from "../core/roles.js"
|
|
32
33
|
import * as users from "../core/users.js"
|
|
33
34
|
import { createLogger } from "../diagnostics/logging.js"
|
|
34
35
|
import { createConvexStore } from "./store.js"
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// `any` data model on purpose: an app's generated QueryCtx/MutationCtx (with its
|
|
38
|
+
// own tables) must be assignable here without a cast.
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
type Db = GenericDatabaseReader<any> | GenericDatabaseWriter<any>
|
|
41
|
+
/** The subset of a Convex query/mutation ctx the system needs. Any app's ctx fits. */
|
|
38
42
|
export interface DbCtx { db: Db; auth: Auth }
|
|
39
43
|
|
|
40
44
|
export interface ConvexSystemOptions extends SystemOptions {
|
|
@@ -55,10 +59,16 @@ export interface ContextOptions { apiKey?: string | undefined; operationId?: str
|
|
|
55
59
|
|
|
56
60
|
const OPERATION_ID = /^[A-Za-z0-9_-]{8,64}$/
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Default mapping. Convex sets `emailVerified` only for OIDC providers; custom
|
|
64
|
+
* JWT providers (WorkOS, Better Auth, most OIDC setups through customJwt) pass
|
|
65
|
+
* the claim through raw as `email_verified`, so read both.
|
|
66
|
+
*/
|
|
67
|
+
export const defaultIdentity = (i: UserIdentity): VerifiedIdentity => {
|
|
59
68
|
const out: VerifiedIdentity = { provider: i.issuer, subject: i.subject }
|
|
60
|
-
if (i.email) out.email = i.email
|
|
61
|
-
|
|
69
|
+
if (typeof i.email === "string" && i.email) out.email = i.email
|
|
70
|
+
const verified = i.emailVerified ?? (i as Record<string, unknown>).email_verified
|
|
71
|
+
if (typeof verified === "boolean") out.emailVerified = verified
|
|
62
72
|
return out
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -303,6 +313,19 @@ export function defineConvexSystem(options: ConvexSystemOptions = {}) {
|
|
|
303
313
|
apiKeys.rotateApiKey(sys, actor, a.apiKeyId, { prefix: a.prefix, hash: a.hash }),
|
|
304
314
|
),
|
|
305
315
|
|
|
316
|
+
// --- Super tools: view as role -----------------------------------------------------------
|
|
317
|
+
/** `{ canViewAs, roleView }` for the caller. */
|
|
318
|
+
roleViewStatus: query({}, (sys, actor) => roleView.roleViewStatus(sys, actor)),
|
|
319
|
+
/** A real Super previews the system as `roleId`. Human sessions only; audited. */
|
|
320
|
+
startRoleView: mutationGeneric({
|
|
321
|
+
args: { roleId: v.string(), operationId: v.optional(v.string()) },
|
|
322
|
+
handler: (ctx, a) => run(ctx, { operationId: a.operationId }, (sys, actor) => roleView.startRoleView(sys, actor, a.roleId)),
|
|
323
|
+
}),
|
|
324
|
+
stopRoleView: mutationGeneric({
|
|
325
|
+
args: { operationId: v.optional(v.string()) },
|
|
326
|
+
handler: (ctx, a) => run(ctx, { operationId: a.operationId }, (sys, actor) => roleView.stopRoleView(sys, actor)),
|
|
327
|
+
}),
|
|
328
|
+
|
|
306
329
|
// --- Feature flags & audit ------------------------------------------------------------
|
|
307
330
|
listFeatureFlags: query({}, (sys, actor) => flags.listFlags(sys, actor)),
|
|
308
331
|
setFeatureFlag: mutation({ flagId: v.string(), value: v.boolean() }, (sys, actor, a) => flags.setFlag(sys, actor, a.flagId, a.value)),
|
package/src/convex/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Convex integration: schema tables, store adapter, system functions and guards.
|
|
2
2
|
export { systemTables } from "./schema.js"
|
|
3
3
|
export { createConvexStore } from "./store.js"
|
|
4
|
-
export { defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ContextOptions, type DbCtx } from "./functions.js"
|
|
4
|
+
export { defaultIdentity, defineConvexSystem, toConvexError, type ConvexSystem, type ConvexSystemOptions, type ContextOptions, type DbCtx } from "./functions.js"
|
|
5
5
|
|
|
6
6
|
// Runtime-agnostic services, for custom Convex functions built on `system.context(ctx)`.
|
|
7
7
|
export * from "../core/index.js"
|
package/src/convex/schema.ts
CHANGED
|
@@ -169,4 +169,7 @@ export const systemTables = {
|
|
|
169
169
|
}).index("by_key", ["key"]),
|
|
170
170
|
|
|
171
171
|
systemMeta: defineTable({ key: v.string(), catalogVersion: v.string(), registeredAt: v.number() }).index("by_key", ["key"]),
|
|
172
|
+
|
|
173
|
+
/** "View as role": one row per Super previewing a role (key = user ID). */
|
|
174
|
+
roleViews: defineTable({ key: v.string(), roleId: v.string(), startedAt: v.number() }).index("by_key", ["key"]),
|
|
172
175
|
}
|
package/src/core/access.ts
CHANGED
|
@@ -74,7 +74,34 @@ export function isSuperUser(sys: SystemContext, user: UserRow | null | undefined
|
|
|
74
74
|
return !!user && user.status === "active" && user.emailVerified && sys.superEmails.has(normalizeEmail(user.email))
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The role a Super is previewing ("view as role"), or null. Only a real Super
|
|
79
|
+
* can have one; an archived or deleted role ends the preview.
|
|
80
|
+
*/
|
|
81
|
+
export async function activeRoleView(sys: SystemContext, user: UserRow | null | undefined): Promise<string | null> {
|
|
82
|
+
if (!user || !isSuperUser(sys, user)) return null
|
|
83
|
+
const view = await sys.store.get("roleViews", user.key)
|
|
84
|
+
if (!view) return null
|
|
85
|
+
const role = await sys.store.get("roles", view.roleId)
|
|
86
|
+
return role && !role.archived ? view.roleId : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Super with Super rights in effect: false while previewing a role. */
|
|
90
|
+
export async function isEffectiveSuperUser(sys: SystemContext, user: UserRow | null | undefined): Promise<boolean> {
|
|
91
|
+
return isSuperUser(sys, user) && !(await activeRoleView(sys, user))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the actor acts as Super right now. A Super previewing a role does
|
|
96
|
+
* not: every check then treats them as a holder of that role.
|
|
97
|
+
*/
|
|
77
98
|
export async function isSuperActor(sys: SystemContext, actor: Actor | null): Promise<boolean> {
|
|
99
|
+
if (!actor || actor.kind !== "user") return false
|
|
100
|
+
return isEffectiveSuperUser(sys, await sys.store.get("users", actor.userId))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Super by account and deployment list, ignoring a role preview (to start or stop one). */
|
|
104
|
+
export async function isRealSuperActor(sys: SystemContext, actor: Actor | null): Promise<boolean> {
|
|
78
105
|
if (!actor || actor.kind !== "user") return false
|
|
79
106
|
return isSuperUser(sys, await sys.store.get("users", actor.userId))
|
|
80
107
|
}
|
|
@@ -123,10 +150,14 @@ type Grantee = { userId: string } | { apiKeyId: string }
|
|
|
123
150
|
|
|
124
151
|
/** Roles currently held by a grantee (excluding archived). Includes implicit roles for users. */
|
|
125
152
|
export async function heldRoleIds(sys: SystemContext, grantee: Grantee): Promise<string[]> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
let ids: string[]
|
|
154
|
+
if ("userId" in grantee) {
|
|
155
|
+
// A Super previewing a role holds exactly that role (plus implicit ones).
|
|
156
|
+
const view = await activeRoleView(sys, await sys.store.get("users", grantee.userId))
|
|
157
|
+
ids = [...(view ? [view] : (await sys.store.findBy("userRoles", "userId", grantee.userId)).map((r) => r.roleId)), ...implicitRoleIds(sys)]
|
|
158
|
+
} else {
|
|
159
|
+
ids = (await sys.store.findBy("apiKeyRoles", "apiKeyId", grantee.apiKeyId)).map((r) => r.roleId)
|
|
160
|
+
}
|
|
130
161
|
const out: string[] = []
|
|
131
162
|
for (const id of new Set(ids)) {
|
|
132
163
|
const role = await sys.store.get("roles", id)
|
|
@@ -167,15 +198,17 @@ async function decideUnsafe(sys: SystemContext, actor: Actor | null, permission:
|
|
|
167
198
|
if (actor.kind === "user") {
|
|
168
199
|
const user = await sys.store.get("users", actor.userId)
|
|
169
200
|
if (!user || user.status !== "active") return deny(permission, "account_inactive", moduleId)
|
|
170
|
-
// Active Super may use every registered module and permission, including disabled
|
|
171
|
-
|
|
201
|
+
// Active Super may use every registered module and permission, including disabled
|
|
202
|
+
// modules, unless previewing a role: then the role's grants below apply.
|
|
203
|
+
if (await isEffectiveSuperUser(sys, user)) return { allowed: true, permission, reason: "super", moduleId }
|
|
172
204
|
if (!isModuleEnabled(sys, moduleRow)) return deny(permission, "module_disabled", moduleId)
|
|
173
205
|
const paths = await grantPaths(sys, { userId: user.key }, permission)
|
|
174
206
|
return paths.length ? { allowed: true, permission, reason: "granted", moduleId, paths } : deny(permission, "not_granted", moduleId)
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
// Agent: active key, active owner, enabled module (no Super exception),
|
|
178
|
-
// permission through key roles AND the owner's current permission.
|
|
210
|
+
// permission through key roles AND the owner's current permission. The owner's
|
|
211
|
+
// real Super status counts here: a role preview never breaks their agents.
|
|
179
212
|
const key = await sys.store.get("apiKeys", actor.apiKeyId)
|
|
180
213
|
if (!key || key.ownerId !== actor.ownerId || apiKeyStatus(key, sys.now()) !== "active") return deny(permission, "key_inactive", moduleId)
|
|
181
214
|
if (perm.humanOnly) return deny(permission, "human_only", moduleId)
|
|
@@ -273,7 +306,7 @@ export async function permissionsOfSubrole(sys: SystemContext, subroleId: string
|
|
|
273
306
|
export async function entitlements(sys: SystemContext, userId: string): Promise<Set<string>> {
|
|
274
307
|
const user = await sys.store.get("users", userId)
|
|
275
308
|
if (!user || user.status !== "active") return new Set()
|
|
276
|
-
if (
|
|
309
|
+
if (await isEffectiveSuperUser(sys, user)) {
|
|
277
310
|
const rows = await sys.store.all("permissions")
|
|
278
311
|
return new Set(rows.filter((r) => r.status === "active" && sys.def.catalog.permissions.has(r.key)).map((r) => r.key))
|
|
279
312
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Decision,
|
|
12
12
|
} from "./access.js"
|
|
13
13
|
import { recordAudit } from "./audit.js"
|
|
14
|
+
import { roleViewStatus, type RoleViewStatus } from "./role-view.js"
|
|
14
15
|
import type { SystemContext } from "./context.js"
|
|
15
16
|
import { notFound, validation } from "./errors.js"
|
|
16
17
|
import { flagValue } from "./feature-flags.js"
|
|
@@ -89,14 +90,18 @@ export interface SessionSummary {
|
|
|
89
90
|
permissions: string[]
|
|
90
91
|
/** Effective flag values in modules the actor can use. */
|
|
91
92
|
flags: Record<string, boolean>
|
|
93
|
+
/** The caller is a real Super and may use "view as role". */
|
|
94
|
+
canViewAs: boolean
|
|
95
|
+
/** The role a Super is previewing; then `actor.isSuper` is false and everything else is that role's. */
|
|
96
|
+
roleView: RoleViewStatus["roleView"]
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
export async function sessionSummary(sys: SystemContext, actor: Actor | null): Promise<SessionSummary> {
|
|
95
|
-
if (!actor) return { actor: null, modules: [], permissions: [], flags: {} }
|
|
100
|
+
if (!actor) return { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
|
|
96
101
|
let actorOut: SessionSummary["actor"]
|
|
97
102
|
if (actor.kind === "user") {
|
|
98
103
|
const user = await sys.store.get("users", actor.userId)
|
|
99
|
-
if (!user || user.status !== "active") return { actor: null, modules: [], permissions: [], flags: {} }
|
|
104
|
+
if (!user || user.status !== "active") return { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
|
|
100
105
|
actorOut = { kind: "user", userId: user.key, email: user.email, isSuper: await isSuperActor(sys, actor) }
|
|
101
106
|
if (user.displayName) actorOut.displayName = user.displayName
|
|
102
107
|
} else {
|
|
@@ -107,5 +112,6 @@ export async function sessionSummary(sys: SystemContext, actor: Actor | null): P
|
|
|
107
112
|
const usable = new Set(modules.map((m) => m.id))
|
|
108
113
|
const flags: Record<string, boolean> = {}
|
|
109
114
|
for (const f of sys.def.catalog.flags.values()) if (usable.has(f.moduleId)) flags[f.id] = await flagValue(sys, f.id)
|
|
110
|
-
|
|
115
|
+
const view = actor.kind === "user" ? await roleViewStatus(sys, actor) : { canViewAs: false, roleView: null }
|
|
116
|
+
return { actor: actorOut, modules, permissions: await effectivePermissions(sys, actor), flags, ...view }
|
|
111
117
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// "View as role": a Super previews the system as a holder of one role. While
|
|
2
|
+
// active, access decisions, entitlements, delegation and the session treat the
|
|
3
|
+
// Super as that role (see access.ts). Human Super sessions only; audited.
|
|
4
|
+
import { activeRoleView, isRealSuperActor, requireHuman, withOperation, type Actor } from "./access.js"
|
|
5
|
+
import { recordAudit } from "./audit.js"
|
|
6
|
+
import type { SystemContext } from "./context.js"
|
|
7
|
+
import { forbidden, notFound, validation } from "./errors.js"
|
|
8
|
+
import { PROFILE_ROLE_ID } from "./system-modules.js"
|
|
9
|
+
|
|
10
|
+
export interface RoleViewStatus {
|
|
11
|
+
/** The caller is a real Super and may start a preview. */
|
|
12
|
+
canViewAs: boolean
|
|
13
|
+
roleView: { roleId: string; roleName: string } | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function roleViewStatus(sys: SystemContext, actor: Actor | null): Promise<RoleViewStatus> {
|
|
17
|
+
if (!actor || actor.kind !== "user" || !(await isRealSuperActor(sys, actor))) return { canViewAs: false, roleView: null }
|
|
18
|
+
const roleId = await activeRoleView(sys, await sys.store.get("users", actor.userId))
|
|
19
|
+
const role = roleId ? await sys.store.get("roles", roleId) : null
|
|
20
|
+
return { canViewAs: true, roleView: role ? { roleId: role.key, roleName: role.name } : null }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function startRoleView(sys: SystemContext, actor: Actor | null, roleId: string): Promise<{ roleId: string; roleName: string }> {
|
|
24
|
+
const human = requireHuman(sys, actor)
|
|
25
|
+
if (!(await isRealSuperActor(sys, human))) throw withOperation(sys, forbidden("super_required"))
|
|
26
|
+
if (roleId === PROFILE_ROLE_ID) throw withOperation(sys, validation("profile_role", ["roleId"]))
|
|
27
|
+
const role = await sys.store.get("roles", roleId)
|
|
28
|
+
if (!role || role.archived) throw withOperation(sys, notFound())
|
|
29
|
+
const existing = await sys.store.get("roleViews", human.userId)
|
|
30
|
+
if (existing) await sys.store.patch("roleViews", human.userId, { roleId, startedAt: sys.now() })
|
|
31
|
+
else await sys.store.insert("roleViews", { key: human.userId, roleId, startedAt: sys.now() })
|
|
32
|
+
await recordAudit(sys, human, { action: "super.role_view.start", targetType: "role", targetId: roleId })
|
|
33
|
+
return { roleId, roleName: role.name }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function stopRoleView(sys: SystemContext, actor: Actor | null): Promise<{ stopped: boolean }> {
|
|
37
|
+
const human = requireHuman(sys, actor)
|
|
38
|
+
const existing = await sys.store.get("roleViews", human.userId)
|
|
39
|
+
if (!existing) return { stopped: false }
|
|
40
|
+
await sys.store.delete("roleViews", human.userId)
|
|
41
|
+
await recordAudit(sys, human, { action: "super.role_view.stop", targetType: "role", targetId: existing.roleId })
|
|
42
|
+
return { stopped: true }
|
|
43
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -143,6 +143,9 @@ export interface SystemOperationRow {
|
|
|
143
143
|
|
|
144
144
|
export interface SystemMetaRow { key: string; catalogVersion: string; registeredAt: number }
|
|
145
145
|
|
|
146
|
+
/** A Super previewing the system as a role. `key` is the Super's user ID. */
|
|
147
|
+
export interface RoleViewRow { key: string; roleId: string; startedAt: number }
|
|
148
|
+
|
|
146
149
|
export interface Tables {
|
|
147
150
|
users: UserRow
|
|
148
151
|
identities: IdentityRow
|
|
@@ -161,6 +164,7 @@ export interface Tables {
|
|
|
161
164
|
auditEvents: AuditEventRow
|
|
162
165
|
systemOperations: SystemOperationRow
|
|
163
166
|
systemMeta: SystemMetaRow
|
|
167
|
+
roleViews: RoleViewRow
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
export type TableName = keyof Tables
|
|
@@ -184,6 +188,7 @@ export const INDEXES = {
|
|
|
184
188
|
auditEvents: ["operationId", "actorUserId", "targetId"],
|
|
185
189
|
systemOperations: [],
|
|
186
190
|
systemMeta: [],
|
|
191
|
+
roleViews: [],
|
|
187
192
|
} as const satisfies { [T in TableName]: readonly (keyof Tables[T] & string)[] }
|
|
188
193
|
|
|
189
194
|
export type IndexField<T extends TableName> = (typeof INDEXES)[T][number] & keyof Tables[T]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// `@digimuza/system-core/finance`: the finance business module (invoices and
|
|
2
|
+
// their approval), reusable by every client app.
|
|
3
|
+
//
|
|
4
|
+
// // convex/schema.ts
|
|
5
|
+
// export default defineSchema({ ...systemTables, ...financeTables })
|
|
6
|
+
// // convex/system.ts
|
|
7
|
+
// export const system = defineConvexSystem({ modules: [finance] })
|
|
8
|
+
// // convex/finance.ts
|
|
9
|
+
// export const { listInvoices, createInvoice, approveInvoice, bulkApproveInvoices } = defineFinance(system)
|
|
10
|
+
//
|
|
11
|
+
// Every function guards itself on the server (permission, module enabled,
|
|
12
|
+
// role view) in the same transaction as the change and writes an audit event.
|
|
13
|
+
import { defineTable, mutationGeneric, queryGeneric } from "convex/server"
|
|
14
|
+
import type { GenericId } from "convex/values"
|
|
15
|
+
import { v } from "convex/values"
|
|
16
|
+
import { isFeatureEnabled, recordAudit, SystemError, type Actor } from "../core/index.js"
|
|
17
|
+
import { toConvexError, type ConvexSystem } from "../convex/functions.js"
|
|
18
|
+
import { defineModule, type PermissionOf } from "../modules/index.js"
|
|
19
|
+
|
|
20
|
+
export const finance = defineModule({
|
|
21
|
+
id: "finance",
|
|
22
|
+
kind: "business",
|
|
23
|
+
description: "Sąskaitų peržiūra ir tvirtinimas",
|
|
24
|
+
permissions: [
|
|
25
|
+
{ id: "finance:invoice:read", name: "Skaityti", description: "Peržiūrėti sąskaitas" },
|
|
26
|
+
{ id: "finance:invoice:create", name: "Kurti", description: "Sukurti sąskaitą" },
|
|
27
|
+
{ id: "finance:invoice:approve", name: "Tvirtinti", description: "Patvirtinti sąskaitą" },
|
|
28
|
+
],
|
|
29
|
+
initialSubroles: [
|
|
30
|
+
{ id: "finance:reader", name: "Skaitytojas", description: "Gali peržiūrėti sąskaitas", permissions: ["finance:invoice:read"] },
|
|
31
|
+
{
|
|
32
|
+
id: "finance:accountant",
|
|
33
|
+
name: "Buhalteris",
|
|
34
|
+
description: "Gali kurti, peržiūrėti ir tvirtinti sąskaitas",
|
|
35
|
+
permissions: ["finance:invoice:read", "finance:invoice:create", "finance:invoice:approve"],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
featureFlags: [{ id: "finance:bulk-approve", description: "Tvirtinti kelias sąskaitas vienu veiksmu", default: false }],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export const FinancePermissions = {
|
|
42
|
+
read: "finance:invoice:read",
|
|
43
|
+
create: "finance:invoice:create",
|
|
44
|
+
approve: "finance:invoice:approve",
|
|
45
|
+
} as const satisfies Record<string, PermissionOf<typeof finance>>
|
|
46
|
+
|
|
47
|
+
export const FINANCE_BULK_APPROVE_FLAG = "finance:bulk-approve"
|
|
48
|
+
export const FINANCE_BULK_LIMIT = 50
|
|
49
|
+
|
|
50
|
+
/** Spread into the app schema next to `systemTables`. */
|
|
51
|
+
export const financeTables = {
|
|
52
|
+
invoices: defineTable({
|
|
53
|
+
number: v.string(),
|
|
54
|
+
customer: v.string(),
|
|
55
|
+
amountCents: v.number(),
|
|
56
|
+
currency: v.string(),
|
|
57
|
+
status: v.union(v.literal("draft"), v.literal("approved")),
|
|
58
|
+
createdBy: v.string(),
|
|
59
|
+
approvedBy: v.optional(v.string()),
|
|
60
|
+
approvedAt: v.optional(v.number()),
|
|
61
|
+
})
|
|
62
|
+
.index("by_number", ["number"])
|
|
63
|
+
.index("by_status", ["status"]),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type InvoiceStatus = "draft" | "approved"
|
|
67
|
+
|
|
68
|
+
/** Convex ID of an invoice in the app's `invoices` table. */
|
|
69
|
+
export type InvoiceId = GenericId<"invoices">
|
|
70
|
+
|
|
71
|
+
export interface InvoiceView {
|
|
72
|
+
id: InvoiceId
|
|
73
|
+
number: string
|
|
74
|
+
customer: string
|
|
75
|
+
amountCents: number
|
|
76
|
+
currency: string
|
|
77
|
+
status: InvoiceStatus
|
|
78
|
+
createdAt: number
|
|
79
|
+
approvedAt: number | null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface InvoiceDoc {
|
|
83
|
+
_id: InvoiceId
|
|
84
|
+
_creationTime: number
|
|
85
|
+
number: string
|
|
86
|
+
customer: string
|
|
87
|
+
amountCents: number
|
|
88
|
+
currency: string
|
|
89
|
+
status: InvoiceStatus
|
|
90
|
+
approvedAt?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const view = (i: InvoiceDoc): InvoiceView => ({
|
|
94
|
+
id: i._id,
|
|
95
|
+
number: i.number,
|
|
96
|
+
customer: i.customer,
|
|
97
|
+
amountCents: i.amountCents,
|
|
98
|
+
currency: i.currency,
|
|
99
|
+
status: i.status,
|
|
100
|
+
createdAt: i._creationTime,
|
|
101
|
+
approvedAt: i.approvedAt ?? null,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const actorId = (a: Actor) => (a.kind === "user" ? a.userId : a.apiKeyId)
|
|
105
|
+
const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.string()) }
|
|
106
|
+
const opts = (a: { apiKey?: string | undefined; operationId?: string | undefined }) => ({ apiKey: a.apiKey, operationId: a.operationId })
|
|
107
|
+
|
|
108
|
+
function fail(code: "NOT_FOUND" | "CONFLICT" | "VALIDATION" | "FORBIDDEN", reason: string, operationId: string): never {
|
|
109
|
+
throw toConvexError(new SystemError(code, undefined, { reason, operationId }), operationId)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The module's Convex functions, bound to the app's system. Re-export them from `convex/finance.ts`. */
|
|
113
|
+
export function defineFinance(system: ConvexSystem) {
|
|
114
|
+
// A loosely typed db handle: the app's schema contains `financeTables`.
|
|
115
|
+
type Db = {
|
|
116
|
+
query(t: "invoices"): {
|
|
117
|
+
withIndex(i: string, f: (q: { eq(field: string, value: unknown): unknown }) => unknown): { order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }; unique(): Promise<InvoiceDoc | null> }
|
|
118
|
+
order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }
|
|
119
|
+
}
|
|
120
|
+
get(t: "invoices", id: string): Promise<InvoiceDoc | null>
|
|
121
|
+
insert(t: "invoices", doc: Record<string, unknown>): Promise<InvoiceId>
|
|
122
|
+
patch(t: "invoices", id: string, doc: Record<string, unknown>): Promise<void>
|
|
123
|
+
}
|
|
124
|
+
const db = (ctx: { db: unknown }) => ctx.db as Db
|
|
125
|
+
|
|
126
|
+
async function approveOne(ctx: { db: unknown }, operationId: string, invoiceId: string, by: string) {
|
|
127
|
+
const invoice = await db(ctx).get("invoices", invoiceId)
|
|
128
|
+
// A missing invoice and one the caller may not see look the same.
|
|
129
|
+
if (!invoice) fail("NOT_FOUND", "invoice_not_found", operationId)
|
|
130
|
+
if (invoice!.status !== "draft") fail("CONFLICT", "invoice_not_draft", operationId)
|
|
131
|
+
await db(ctx).patch("invoices", invoiceId, { status: "approved", approvedBy: by, approvedAt: Date.now() })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
listInvoices: queryGeneric({
|
|
136
|
+
args: { ...actorArgs, status: v.optional(v.union(v.literal("draft"), v.literal("approved"))) },
|
|
137
|
+
handler: async (ctx, a): Promise<InvoiceView[]> => {
|
|
138
|
+
await system.requirePermission(ctx, FinancePermissions.read, opts(a))
|
|
139
|
+
const q = db(ctx).query("invoices")
|
|
140
|
+
const rows = a.status ? await q.withIndex("by_status", (i) => i.eq("status", a.status)).order("desc").take(100) : await q.order("desc").take(100)
|
|
141
|
+
return rows.map(view)
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
createInvoice: mutationGeneric({
|
|
146
|
+
args: { ...actorArgs, number: v.string(), customer: v.string(), amountCents: v.number(), currency: v.optional(v.string()) },
|
|
147
|
+
handler: async (ctx, a): Promise<{ invoiceId: InvoiceId; operationId: string }> => {
|
|
148
|
+
const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.create, opts(a))
|
|
149
|
+
const number = a.number.trim()
|
|
150
|
+
const customer = a.customer.trim()
|
|
151
|
+
if (!number || !customer || !Number.isInteger(a.amountCents) || a.amountCents <= 0) fail("VALIDATION", "invalid_invoice", sys.operationId)
|
|
152
|
+
const currency = (a.currency ?? "EUR").toUpperCase()
|
|
153
|
+
if (!/^[A-Z]{3}$/.test(currency)) fail("VALIDATION", "invalid_currency", sys.operationId)
|
|
154
|
+
const existing = await db(ctx).query("invoices").withIndex("by_number", (i) => i.eq("number", number)).unique()
|
|
155
|
+
if (existing) fail("CONFLICT", "invoice_number_taken", sys.operationId)
|
|
156
|
+
const invoiceId = await db(ctx).insert("invoices", { number, customer, amountCents: a.amountCents, currency, status: "draft", createdBy: actorId(actor) })
|
|
157
|
+
await recordAudit(sys, actor, { action: "finance.invoice.create", targetType: "invoice", targetId: invoiceId, changes: { number } })
|
|
158
|
+
return { invoiceId, operationId: sys.operationId }
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
approveInvoice: mutationGeneric({
|
|
163
|
+
args: { ...actorArgs, invoiceId: v.id("invoices") },
|
|
164
|
+
handler: async (ctx, a): Promise<{ operationId: string }> => {
|
|
165
|
+
const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
|
|
166
|
+
await approveOne(ctx, sys.operationId, a.invoiceId, actorId(actor))
|
|
167
|
+
await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: a.invoiceId })
|
|
168
|
+
return { operationId: sys.operationId }
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
|
|
172
|
+
/** Behind the `finance:bulk-approve` flag. The flag never grants the permission. */
|
|
173
|
+
bulkApproveInvoices: mutationGeneric({
|
|
174
|
+
args: { ...actorArgs, invoiceIds: v.array(v.id("invoices")) },
|
|
175
|
+
handler: async (ctx, a): Promise<{ approved: number; operationId: string }> => {
|
|
176
|
+
const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
|
|
177
|
+
if (!(await isFeatureEnabled(sys, FINANCE_BULK_APPROVE_FLAG))) fail("FORBIDDEN", "flag_off", sys.operationId)
|
|
178
|
+
if (a.invoiceIds.length > FINANCE_BULK_LIMIT) fail("VALIDATION", "too_many", sys.operationId)
|
|
179
|
+
for (const id of a.invoiceIds) {
|
|
180
|
+
await approveOne(ctx, sys.operationId, id, actorId(actor))
|
|
181
|
+
await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: id, changes: { bulk: true } })
|
|
182
|
+
}
|
|
183
|
+
return { approved: a.invoiceIds.length, operationId: sys.operationId }
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type FinanceFunctions = ReturnType<typeof defineFinance>
|
package/src/react/index.tsx
CHANGED
|
@@ -13,9 +13,9 @@ export type SessionState =
|
|
|
13
13
|
| ({ status: "anonymous" } & EmptySession)
|
|
14
14
|
| ({ status: "ready" } & SessionSummary & { actor: NonNullable<SessionSummary["actor"]> })
|
|
15
15
|
|
|
16
|
-
type EmptySession =
|
|
16
|
+
type EmptySession = Omit<SessionSummary, "actor"> & { actor: null }
|
|
17
17
|
|
|
18
|
-
const EMPTY: EmptySession = { actor: null, modules: [], permissions: [], flags: {} }
|
|
18
|
+
const EMPTY: EmptySession = { actor: null, modules: [], permissions: [], flags: {}, canViewAs: false, roleView: null }
|
|
19
19
|
const LOADING: SessionState = { status: "loading", ...EMPTY }
|
|
20
20
|
|
|
21
21
|
const SessionContext = createContext<SessionState | null>(null)
|
|
@@ -59,6 +59,16 @@ export function useCan(permission: string): boolean {
|
|
|
59
59
|
return useSystemSession().permissions.includes(permission)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* "View as role" state: `canViewAs` for a real Super, `roleView` while a preview
|
|
64
|
+
* is active (then the rest of the session is that role's). Start and stop with
|
|
65
|
+
* the `startRoleView` / `stopRoleView` mutations.
|
|
66
|
+
*/
|
|
67
|
+
export function useRoleView(): Pick<SessionSummary, "canViewAs" | "roleView"> {
|
|
68
|
+
const { canViewAs, roleView } = useSystemSession()
|
|
69
|
+
return { canViewAs, roleView }
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
export function useFeatureFlag(flagId: string): boolean {
|
|
63
73
|
return useSystemSession().flags[flagId] === true
|
|
64
74
|
}
|