@softr/sdk 0.0.44 → 0.0.45-alpha.1
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/dist/auth/context.d.ts +1 -1
- package/dist/auth/isomorphic.d.ts +2 -19
- package/dist/auth/isomorphic.js +2 -24
- package/dist/auth/permissions.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/package.js +1 -1
- package/dist/visibility/audience.d.ts +31 -0
- package/dist/visibility/audience.js +25 -0
- package/dist/visibility/route.d.ts +71 -0
- package/dist/visibility/route.js +83 -0
- package/dist/visibility/visibility.d.ts +76 -0
- package/dist/visibility/visibility.js +63 -0
- package/package.json +33 -10
- package/skills/softr-sdk/SKILL.md +9 -6
- package/skills/softr-sdk/auth/SKILL.md +2 -2
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +2 -2
- package/skills/softr-sdk/current-user/SKILL.md +12 -31
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +2 -2
- package/skills/softr-sdk/record-mutations/SKILL.md +2 -2
- package/skills/softr-sdk/record-queries/SKILL.md +2 -2
- package/skills/softr-sdk/region-visibility/SKILL.md +63 -0
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +33 -28
- package/skills/softr-sdk/workflows/SKILL.md +2 -2
- package/dist/routes/visibility.d.ts +0 -142
- package/dist/routes/visibility.js +0 -115
package/dist/auth/context.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Permissions } from "./permissions.js";
|
|
|
6
6
|
type Session = {
|
|
7
7
|
/** The visitor, or `null` when signed out. */
|
|
8
8
|
user: User | null;
|
|
9
|
-
/**
|
|
9
|
+
/** The visitor's global data restrictions. An escape hatch; the SDK already checks them. */
|
|
10
10
|
permissions: Permissions;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { User } from "./user.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Permissions } from "./permissions.js";
|
|
3
3
|
import { Session, SoftrContext } from "./context.js";
|
|
4
4
|
import * as _tanstack_react_start0 from "@tanstack/react-start";
|
|
5
5
|
|
|
@@ -51,22 +51,5 @@ declare function initialSoftrContext(): SoftrContext;
|
|
|
51
51
|
* }
|
|
52
52
|
*/
|
|
53
53
|
declare function useCurrentUser(): User | null;
|
|
54
|
-
/**
|
|
55
|
-
* Resolves what the current user may do with one datasource.
|
|
56
|
-
*
|
|
57
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
58
|
-
* let this stand in for a permission check.
|
|
59
|
-
*
|
|
60
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
61
|
-
*
|
|
62
|
-
* @example
|
|
63
|
-
* function NewRecordButton() {
|
|
64
|
-
* const { create } = usePermissions(datasourceId)
|
|
65
|
-
* return create ? <button>New record</button> : null
|
|
66
|
-
* }
|
|
67
|
-
*
|
|
68
|
-
* @throws if the application has no such datasource.
|
|
69
|
-
*/
|
|
70
|
-
declare function usePermissions(datasourceId: string): DatasourcePermissions;
|
|
71
54
|
//#endregion
|
|
72
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
55
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assert, panic
|
|
1
|
+
import { assert, panic } from "../assertions.js";
|
|
2
2
|
import { writeCookies } from "../cookies.js";
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
|
|
@@ -87,28 +87,6 @@ function useCurrentUser() {
|
|
|
87
87
|
select: (context) => context.user
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Resolves what the current user may do with one datasource.
|
|
92
|
-
*
|
|
93
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
94
|
-
* let this stand in for a permission check.
|
|
95
|
-
*
|
|
96
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* function NewRecordButton() {
|
|
100
|
-
* const { create } = usePermissions(datasourceId)
|
|
101
|
-
* return create ? <button>New record</button> : null
|
|
102
|
-
* }
|
|
103
|
-
*
|
|
104
|
-
* @throws if the application has no such datasource.
|
|
105
|
-
*/
|
|
106
|
-
function usePermissions(datasourceId) {
|
|
107
|
-
return useRouteContext({
|
|
108
|
-
from: rootRouteId,
|
|
109
|
-
select: (context) => requireDefined(context.permissions.datasources[datasourceId], `the application has no datasource "${datasourceId}"`)
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
90
|
|
|
113
91
|
//#endregion
|
|
114
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
92
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation,
|
|
|
3
3
|
import { FileAttachment } from "./core/file-upload.js";
|
|
4
4
|
import { FileItem, useFileUpload } from "./core/use-file-upload.js";
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
7
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
8
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
8
9
|
import { useUserTimezone } from "./timezone.js";
|
|
9
10
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
10
|
-
|
|
11
|
+
import { Audience } from "./visibility/audience.js";
|
|
12
|
+
export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useFileUpload } from "./core/use-file-upload.js";
|
|
2
2
|
import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
4
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
5
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
5
6
|
import { useUserTimezone } from "./timezone.js";
|
|
6
7
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
8
|
+
import { Audience } from "./visibility/audience.js";
|
|
7
9
|
|
|
8
|
-
export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload,
|
|
10
|
+
export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Visibility } from "./visibility.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.d.ts
|
|
5
|
+
type Props = {
|
|
6
|
+
visibility: Visibility;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
12
|
+
*
|
|
13
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
14
|
+
* builder in Studio.
|
|
15
|
+
*
|
|
16
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
20
|
+
*
|
|
21
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
22
|
+
* <DangerZone />
|
|
23
|
+
* </Audience>
|
|
24
|
+
*/
|
|
25
|
+
declare function Audience({
|
|
26
|
+
visibility,
|
|
27
|
+
children,
|
|
28
|
+
fallback
|
|
29
|
+
}: Props): ReactNode;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Audience };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { checkAccess } from "./visibility.js";
|
|
2
|
+
import { useCurrentUser } from "../auth/isomorphic.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
7
|
+
*
|
|
8
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
9
|
+
* builder in Studio.
|
|
10
|
+
*
|
|
11
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
15
|
+
*
|
|
16
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
17
|
+
* <DangerZone />
|
|
18
|
+
* </Audience>
|
|
19
|
+
*/
|
|
20
|
+
function Audience({ visibility, children, fallback = null }) {
|
|
21
|
+
return checkAccess(visibility, { user: useCurrentUser() }).ok ? children : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { Audience };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule } from "./visibility.js";
|
|
2
|
+
|
|
3
|
+
//#region src/visibility/route.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a guard does when it denies a visitor: redirect to a path, or throw an
|
|
7
|
+
* {@link AccessError} for the route's `errorComponent` to render. A bare string
|
|
8
|
+
* is shorthand for redirecting there.
|
|
9
|
+
*/
|
|
10
|
+
type AccessBehaviour = string | {
|
|
11
|
+
strategy: "redirect";
|
|
12
|
+
to: string;
|
|
13
|
+
} | {
|
|
14
|
+
strategy: "throw";
|
|
15
|
+
};
|
|
16
|
+
/** Per-reason overrides of the default {@link AccessBehaviour}. */
|
|
17
|
+
type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
|
|
18
|
+
/**
|
|
19
|
+
* The access error a guard throws. It is plain data, not an `Error` subclass, so
|
|
20
|
+
* take it as `unknown` in an `errorComponent` and narrow it with
|
|
21
|
+
* {@link isAccessError}. It carries the reason and nothing else: the error is
|
|
22
|
+
* serialized into the page, so it never names the groups the route wanted.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* errorComponent: ({ error }: { error: unknown }) =>
|
|
26
|
+
* isAccessError(error) ? <NoAccess /> : <AppError />
|
|
27
|
+
*/
|
|
28
|
+
type AccessError = {
|
|
29
|
+
type: "access_error";
|
|
30
|
+
reason: AccessReason;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
34
|
+
*
|
|
35
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
36
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
37
|
+
*/
|
|
38
|
+
declare function isAccessError(error: unknown): error is AccessError;
|
|
39
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
40
|
+
declare function isUnauthenticatedError(error: unknown): error is AccessError;
|
|
41
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
42
|
+
declare function isForbiddenError(error: unknown): error is AccessError;
|
|
43
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
44
|
+
declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
|
|
45
|
+
/**
|
|
46
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
47
|
+
*
|
|
48
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
49
|
+
* change what any one reason does.
|
|
50
|
+
*
|
|
51
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
55
|
+
*/
|
|
56
|
+
declare function guardRoute(visibility: Visibility, overrides?: GuardOverrides): (opts: {
|
|
57
|
+
context: AccessContext;
|
|
58
|
+
}) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
61
|
+
* checks after the rule. It takes no overrides.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* beforeLoad: ({ context, params }) => {
|
|
65
|
+
* assertRouteAccess(visibility, context)
|
|
66
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
declare function assertRouteAccess<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): asserts context is Narrowed<R>;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { panic } from "../assertions.js";
|
|
2
|
+
import { ACCESS_REASONS, checkAccess } from "./visibility.js";
|
|
3
|
+
import { redirect } from "@tanstack/react-router";
|
|
4
|
+
|
|
5
|
+
//#region src/visibility/route.ts
|
|
6
|
+
function isAccessReason(value) {
|
|
7
|
+
return ACCESS_REASONS.some((reason) => reason === value);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
11
|
+
*
|
|
12
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
13
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
14
|
+
*/
|
|
15
|
+
function isAccessError(error) {
|
|
16
|
+
if (typeof error !== "object" || error === null) return false;
|
|
17
|
+
if (!("type" in error) || error.type !== "access_error") return false;
|
|
18
|
+
return "reason" in error && isAccessReason(error.reason);
|
|
19
|
+
}
|
|
20
|
+
function isReason(error, reason) {
|
|
21
|
+
return isAccessError(error) && error.reason === reason;
|
|
22
|
+
}
|
|
23
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
24
|
+
function isUnauthenticatedError(error) {
|
|
25
|
+
return isReason(error, "unauthenticated");
|
|
26
|
+
}
|
|
27
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
28
|
+
function isForbiddenError(error) {
|
|
29
|
+
return isReason(error, "forbidden");
|
|
30
|
+
}
|
|
31
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
32
|
+
function isAlreadyAuthenticatedError(error) {
|
|
33
|
+
return isReason(error, "alreadyAuthenticated");
|
|
34
|
+
}
|
|
35
|
+
/** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
|
|
36
|
+
function thrownFor(reason, overrides) {
|
|
37
|
+
const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
|
|
38
|
+
const resolved = typeof behaviour === "string" ? {
|
|
39
|
+
strategy: "redirect",
|
|
40
|
+
to: behaviour
|
|
41
|
+
} : behaviour;
|
|
42
|
+
if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
|
|
43
|
+
return {
|
|
44
|
+
type: "access_error",
|
|
45
|
+
reason
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
50
|
+
*
|
|
51
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
52
|
+
* change what any one reason does.
|
|
53
|
+
*
|
|
54
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
58
|
+
*/
|
|
59
|
+
function guardRoute(visibility, overrides) {
|
|
60
|
+
return ({ context }) => {
|
|
61
|
+
const result = checkAccess(visibility, context);
|
|
62
|
+
if (result.ok) return;
|
|
63
|
+
throw thrownFor(result.reason, overrides);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
68
|
+
* checks after the rule. It takes no overrides.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* beforeLoad: ({ context, params }) => {
|
|
72
|
+
* assertRouteAccess(visibility, context)
|
|
73
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
74
|
+
* }
|
|
75
|
+
*/
|
|
76
|
+
function assertRouteAccess(visibility, context) {
|
|
77
|
+
const result = checkAccess(visibility, context);
|
|
78
|
+
if (result.ok) return;
|
|
79
|
+
throw thrownFor(result.reason, void 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
2
|
+
import { SoftrContext } from "../auth/context.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/visibility.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who may see a route.
|
|
8
|
+
*
|
|
9
|
+
* - `ALL_USERS` — everyone, signed in or not.
|
|
10
|
+
* - `NON_LOGGED_IN_USERS` — signed-out visitors only.
|
|
11
|
+
* - `LOGGED_IN_USERS` — signed-in visitors; with `userGroupIds`, only members of
|
|
12
|
+
* at least one of those groups.
|
|
13
|
+
*/
|
|
14
|
+
type VisibilityRule = {
|
|
15
|
+
userGroup: "ALL_USERS";
|
|
16
|
+
} | {
|
|
17
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
+
} | {
|
|
19
|
+
userGroup: "LOGGED_IN_USERS";
|
|
20
|
+
userGroupIds?: Array<SoftrUserGroupId>;
|
|
21
|
+
};
|
|
22
|
+
declare const visibilityBrand: unique symbol;
|
|
23
|
+
/** A rule {@link defineVisibility} has produced. */
|
|
24
|
+
type Visibility<R extends VisibilityRule = VisibilityRule> = R & {
|
|
25
|
+
readonly [visibilityBrand]: "visibility";
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Why a visitor was denied.
|
|
29
|
+
*
|
|
30
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
31
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
32
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
33
|
+
*/
|
|
34
|
+
declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
|
|
35
|
+
type AccessReason = (typeof ACCESS_REASONS)[number];
|
|
36
|
+
type AccessCheck = {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: AccessReason;
|
|
41
|
+
};
|
|
42
|
+
/** The one field of {@link SoftrContext} every access check reads. */
|
|
43
|
+
type AccessContext = Pick<SoftrContext, "user">;
|
|
44
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
45
|
+
declare function checkAccess(visibility: Visibility, context: AccessContext): AccessCheck;
|
|
46
|
+
type Narrowed<R extends VisibilityRule> = R extends {
|
|
47
|
+
userGroup: "LOGGED_IN_USERS";
|
|
48
|
+
} ? AccessContext & {
|
|
49
|
+
user: User;
|
|
50
|
+
} : R extends {
|
|
51
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
52
|
+
} ? AccessContext & {
|
|
53
|
+
user: null;
|
|
54
|
+
} : AccessContext;
|
|
55
|
+
/**
|
|
56
|
+
* Declares who may see a route or a region of the page.
|
|
57
|
+
*
|
|
58
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
59
|
+
* declarations out of the source to publish the app's route visibility.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
63
|
+
*
|
|
64
|
+
* export const Route = createFileRoute("/about")({
|
|
65
|
+
* beforeLoad: guardRoute(visibility),
|
|
66
|
+
* component: About,
|
|
67
|
+
* })
|
|
68
|
+
*/
|
|
69
|
+
declare function defineVisibility<const R extends VisibilityRule>(rule: R): Visibility<R>;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
72
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
73
|
+
*/
|
|
74
|
+
declare function isAuthorized<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): context is Narrowed<R>;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule, checkAccess, defineVisibility, isAuthorized };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/visibility/visibility.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why a visitor was denied.
|
|
4
|
+
*
|
|
5
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
6
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
7
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
8
|
+
*/
|
|
9
|
+
const ACCESS_REASONS = [
|
|
10
|
+
"unauthenticated",
|
|
11
|
+
"forbidden",
|
|
12
|
+
"alreadyAuthenticated"
|
|
13
|
+
];
|
|
14
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
15
|
+
function checkAccess(visibility, context) {
|
|
16
|
+
const { user } = context;
|
|
17
|
+
switch (visibility.userGroup) {
|
|
18
|
+
case "ALL_USERS": return { ok: true };
|
|
19
|
+
case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
|
|
20
|
+
ok: false,
|
|
21
|
+
reason: "alreadyAuthenticated"
|
|
22
|
+
};
|
|
23
|
+
case "LOGGED_IN_USERS": {
|
|
24
|
+
if (user === null) return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: "unauthenticated"
|
|
27
|
+
};
|
|
28
|
+
const required = visibility.userGroupIds ?? [];
|
|
29
|
+
if (required.length === 0) return { ok: true };
|
|
30
|
+
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: "forbidden"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Declares who may see a route or a region of the page.
|
|
39
|
+
*
|
|
40
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
41
|
+
* declarations out of the source to publish the app's route visibility.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
45
|
+
*
|
|
46
|
+
* export const Route = createFileRoute("/about")({
|
|
47
|
+
* beforeLoad: guardRoute(visibility),
|
|
48
|
+
* component: About,
|
|
49
|
+
* })
|
|
50
|
+
*/
|
|
51
|
+
function defineVisibility(rule) {
|
|
52
|
+
return rule;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
56
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
57
|
+
*/
|
|
58
|
+
function isAuthorized(visibility, context) {
|
|
59
|
+
return checkAccess(visibility, context).ok;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ACCESS_REASONS, checkAccess, defineVisibility, isAuthorized };
|
package/package.json
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.45-alpha.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./client": {
|
|
11
|
+
"types": "./dist/client.d.ts",
|
|
12
|
+
"import": "./dist/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./dist/server.d.ts",
|
|
16
|
+
"import": "./dist/server.js"
|
|
17
|
+
},
|
|
18
|
+
"./vite": {
|
|
19
|
+
"types": "./dist/vite.d.ts",
|
|
20
|
+
"import": "./dist/vite.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"softr-sdk": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"schema",
|
|
29
|
+
"skills",
|
|
30
|
+
"!skills/_artifacts"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
14
35
|
"scripts": {
|
|
15
36
|
"build": "tsdown",
|
|
16
37
|
"test": "node --import ./src/test-utils/setup.ts --test",
|
|
@@ -42,7 +63,9 @@
|
|
|
42
63
|
"optional": true
|
|
43
64
|
}
|
|
44
65
|
},
|
|
45
|
-
"keywords": [
|
|
66
|
+
"keywords": [
|
|
67
|
+
"tanstack-intent"
|
|
68
|
+
],
|
|
46
69
|
"intent": {
|
|
47
70
|
"version": 1,
|
|
48
71
|
"repo": "@softr/sdk",
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.45
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
# Softr SDK core
|
|
@@ -19,7 +19,7 @@ metadata:
|
|
|
19
19
|
`@softr/sdk` is how a Softr application talks to Softr. The visitor's identity, who may see a route, records in a datasource, signing in: all of it goes through this package. Everything else is an ordinary TanStack Start app. Routes, server functions, loaders and middleware are Start's, not Softr's.
|
|
20
20
|
|
|
21
21
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
22
|
-
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling.
|
|
22
|
+
> **CRITICAL**: Every route file must declare its audience with `defineVisibility` and guard itself with that rule in `beforeLoad`. There is no default and nothing is inherited from a sibling.
|
|
23
23
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
24
24
|
> **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
|
|
25
25
|
|
|
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
29
29
|
|
|
30
30
|
| Import | Runs | Holds |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `
|
|
32
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
33
33
|
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
34
34
|
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
35
35
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -39,7 +39,8 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
39
39
|
| Task | Sub-Skill |
|
|
40
40
|
| --- | --- |
|
|
41
41
|
| Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
|
|
42
|
-
|
|
|
42
|
+
| Show part of a page to some visitors only | [region-visibility/SKILL.md](./region-visibility/SKILL.md) |
|
|
43
|
+
| Read the signed-in visitor | [current-user/SKILL.md](./current-user/SKILL.md) |
|
|
43
44
|
| Fetch records from a datasource | [record-queries/SKILL.md](./record-queries/SKILL.md) |
|
|
44
45
|
| Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
|
|
45
46
|
| Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
|
|
@@ -53,7 +54,10 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
53
54
|
Adding a page, or changing who may see one?
|
|
54
55
|
load softr-sdk/routes-and-visibility
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
Hiding a region of a page from some visitors?
|
|
58
|
+
load softr-sdk/region-visibility
|
|
59
|
+
|
|
60
|
+
Showing the visitor's name, email or groups?
|
|
57
61
|
load softr-sdk/current-user
|
|
58
62
|
|
|
59
63
|
Putting records on a page, in a table, a list or a detail view?
|
|
@@ -123,7 +127,6 @@ export function getRouter() {
|
|
|
123
127
|
```ts
|
|
124
128
|
type SoftrContext = {
|
|
125
129
|
user: User | null // null means signed out
|
|
126
|
-
permissions: { datasources: Record<string, DatasourcePermissions> }
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
type User = {
|
|
@@ -16,10 +16,10 @@ description: >-
|
|
|
16
16
|
metadata:
|
|
17
17
|
type: sub-skill
|
|
18
18
|
library: softr-sdk
|
|
19
|
-
library_version: 0.0.
|
|
19
|
+
library_version: 0.0.45
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
22
|
+
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
23
23
|
|
|
24
24
|
# Auth
|
|
25
25
|
|
|
@@ -12,10 +12,10 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.45
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
18
|
+
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
19
19
|
|
|
20
20
|
# Charts and metrics
|
|
21
21
|
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: current-user
|
|
3
3
|
description: >-
|
|
4
|
-
Read the signed-in Softr visitor
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
permissions are a rendering hint rather than an authorization check.
|
|
4
|
+
Read the signed-in Softr visitor: useCurrentUser and the User type,
|
|
5
|
+
context.user in a route's beforeLoad, and context.user inside a server
|
|
6
|
+
function handler, where Start types it from the app's registered request
|
|
7
|
+
middleware. Covers the null guest and the SoftrUserGroups module augmentation
|
|
8
|
+
that narrows group ids to your own.
|
|
10
9
|
metadata:
|
|
11
10
|
type: sub-skill
|
|
12
11
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.45
|
|
14
13
|
---
|
|
15
14
|
|
|
16
|
-
**Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
|
-
|
|
18
15
|
# The current user
|
|
19
16
|
|
|
20
17
|
One visitor is resolved once per request and published three ways: as a hook in components, as route context in `beforeLoad`, and on the `context` argument inside a server function. All three read the same `SoftrContext`. Which one you reach for depends only on where the code runs.
|
|
@@ -24,7 +21,7 @@ One visitor is resolved once per request and published three ways: as a hook in
|
|
|
24
21
|
Nothing to install or register, since the app is already wired. Import from the root entrypoint:
|
|
25
22
|
|
|
26
23
|
```ts
|
|
27
|
-
import { useCurrentUser
|
|
24
|
+
import { useCurrentUser } from '@softr/sdk'
|
|
28
25
|
import type { User } from '@softr/sdk'
|
|
29
26
|
```
|
|
30
27
|
|
|
@@ -56,12 +53,12 @@ It returns `User | null` and never suspends or fetches. It selects off root rout
|
|
|
56
53
|
|
|
57
54
|
```tsx
|
|
58
55
|
export const Route = createFileRoute('/_app/invoices')({
|
|
59
|
-
beforeLoad: visibility
|
|
56
|
+
beforeLoad: guardRoute(visibility),
|
|
60
57
|
loader: ({ context }) => fetchInvoicesFor({ data: { userId: context.user!.id } }),
|
|
61
58
|
})
|
|
62
59
|
```
|
|
63
60
|
|
|
64
|
-
A `
|
|
61
|
+
A `guardRoute` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null` further down the route. `isAuthorized(visibility, context)` is the check that narrows it, so prefer that over `!`. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
|
|
65
62
|
|
|
66
63
|
### In a server function: `context`
|
|
67
64
|
|
|
@@ -85,24 +82,7 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
|
85
82
|
})
|
|
86
83
|
```
|
|
87
84
|
|
|
88
|
-
`context.user`
|
|
89
|
-
|
|
90
|
-
### Hiding UI the visitor cannot use
|
|
91
|
-
|
|
92
|
-
```tsx
|
|
93
|
-
import { usePermissions } from '@softr/sdk'
|
|
94
|
-
|
|
95
|
-
const INVOICES = '00000000-0000-0000-0000-000000000001' // the registered datasource's id
|
|
96
|
-
|
|
97
|
-
function NewInvoiceButton() {
|
|
98
|
-
const { create } = usePermissions(INVOICES)
|
|
99
|
-
return create ? <button>New invoice</button> : null
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
`usePermissions(datasourceId)` returns `{ read, create, update, delete }` for one datasource. The id is the opaque UUID from the table's registration on the application, the same one `ds.*` takes — not the table id, and never built from the table's name. It hides a control. It does not protect what the control does.
|
|
104
|
-
|
|
105
|
-
Pass an id the visitor's session has no entry for and it throws while rendering, so a typo takes the page down rather than returning four falses.
|
|
85
|
+
`context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
|
|
106
86
|
|
|
107
87
|
### Narrowing group ids to your own
|
|
108
88
|
|
|
@@ -122,6 +102,7 @@ With this declaration, `user.userGroupIds` and every `userGroupIds` the SDK acce
|
|
|
122
102
|
|
|
123
103
|
## References
|
|
124
104
|
|
|
125
|
-
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized
|
|
105
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized` narrows `context.user` while applying the route's rule
|
|
106
|
+
- [region-visibility/SKILL.md](../region-visibility/SKILL.md): rendering a region only to the visitors a rule admits
|
|
126
107
|
- [record-queries/SKILL.md](../record-queries/SKILL.md): filtering a query by the visitor, and who really authorizes the read
|
|
127
108
|
- [auth/SKILL.md](../auth/SKILL.md): what changes the value `useCurrentUser` returns
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.45
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,10 +9,10 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.45
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
15
|
+
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
16
16
|
|
|
17
17
|
# Integrations
|
|
18
18
|
|
|
@@ -11,10 +11,10 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.45
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
17
|
+
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
18
18
|
|
|
19
19
|
# Writing records
|
|
20
20
|
|
|
@@ -13,10 +13,10 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.45
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
19
|
+
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
20
20
|
|
|
21
21
|
# Reading records
|
|
22
22
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: region-visibility
|
|
3
|
+
description: >-
|
|
4
|
+
Show part of a page only to the visitors a rule admits: the Audience
|
|
5
|
+
component, its children and fallback, where a region's rule may be declared,
|
|
6
|
+
and why hiding a control is not authorization. Use when a button, a column, a
|
|
7
|
+
nav item or a panel should appear for some visitors and not others, on a page
|
|
8
|
+
everyone may open. Gating the whole route is routes-and-visibility.
|
|
9
|
+
metadata:
|
|
10
|
+
type: sub-skill
|
|
11
|
+
library: softr-sdk
|
|
12
|
+
library_version: 0.0.45
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
**Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
|
|
16
|
+
|
|
17
|
+
# Region visibility
|
|
18
|
+
|
|
19
|
+
A route's rule decides who may open the page. A region's rule decides what they see once they are on it. The two are independent: an admin panel on a page every signed-in visitor may open is a region, and so is a refund button only staff should reach for.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
Wrap the region in `Audience` and give it a rule:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Audience, defineVisibility } from '@softr/sdk'
|
|
27
|
+
|
|
28
|
+
const staffOnly = defineVisibility({
|
|
29
|
+
userGroup: 'LOGGED_IN_USERS',
|
|
30
|
+
userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function InvoiceActions({ invoice }: { invoice: Invoice }) {
|
|
34
|
+
return (
|
|
35
|
+
<Audience visibility={staffOnly} fallback={<p>Contact your account manager to refund.</p>}>
|
|
36
|
+
<RefundButton invoice={invoice} />
|
|
37
|
+
</Audience>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`Audience` renders its children for the visitors the rule admits and `fallback` for everyone else. Without a `fallback` it renders nothing.
|
|
43
|
+
|
|
44
|
+
The rules are the same four a route uses, and [routes-and-visibility](../routes-and-visibility/SKILL.md) covers them.
|
|
45
|
+
|
|
46
|
+
## Core Patterns
|
|
47
|
+
|
|
48
|
+
### Where the rule is declared
|
|
49
|
+
|
|
50
|
+
A region's rule is an ordinary value. It can sit beside the component, or come from a module the whole app shares so several regions read the same one. A route's rule cannot. Softr reads that one out of the route file, so it has to be declared there.
|
|
51
|
+
|
|
52
|
+
Two files cannot hold a region's rule. Any declaration in `__root.tsx` is a build error. In the lazy half of a split route, Softr takes the first declaration as the route's own. Put the rule in the component's file or a shared module and import it.
|
|
53
|
+
|
|
54
|
+
A route file may declare a region's rule alongside its own. The route's rule is the one `beforeLoad` names.
|
|
55
|
+
|
|
56
|
+
### Hiding a control is not authorization
|
|
57
|
+
|
|
58
|
+
`Audience` decides what to render and nothing else. The server function behind a hidden button is still reachable on its own, and Softr authorizes that request whatever the page rendered. If a region hides something sensitive, declare the action behind it with the permissions it should have.
|
|
59
|
+
|
|
60
|
+
## References
|
|
61
|
+
|
|
62
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): the rule shapes, and gating a whole page
|
|
63
|
+
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a rule has admitted
|
|
@@ -3,19 +3,20 @@ name: routes-and-visibility
|
|
|
3
3
|
description: >-
|
|
4
4
|
Add a route file to a Softr app and declare who may see it: defineVisibility
|
|
5
5
|
with ALL_USERS, LOGGED_IN_USERS or NON_LOGGED_IN_USERS, userGroupIds, and the
|
|
6
|
-
|
|
7
|
-
per-reason overrides, the
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
ways to apply a rule: guardRoute and assertRouteAccess in a route,
|
|
7
|
+
isAuthorized and checkAccess anywhere. Covers per-reason overrides, the
|
|
8
|
+
redirect and throw strategies, rendering an access error with
|
|
9
|
+
isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
10
|
+
isAlreadyAuthenticatedError, guarding a layout, and the lint rule that requires a
|
|
11
|
+
declaration alongside the rules that reject a duplicate, computed or
|
|
12
|
+
root-level one.
|
|
12
13
|
metadata:
|
|
13
14
|
type: sub-skill
|
|
14
15
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.45
|
|
16
17
|
---
|
|
17
18
|
|
|
18
|
-
**Boundary.** `defineVisibility`, the
|
|
19
|
+
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
19
20
|
|
|
20
21
|
# Route visibility
|
|
21
22
|
|
|
@@ -27,12 +28,12 @@ Every route file, without exception, looks like this:
|
|
|
27
28
|
|
|
28
29
|
```tsx
|
|
29
30
|
import { createFileRoute } from '@tanstack/react-router'
|
|
30
|
-
import { defineVisibility } from '@softr/sdk'
|
|
31
|
+
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
31
32
|
|
|
32
33
|
const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
|
|
33
34
|
|
|
34
35
|
export const Route = createFileRoute('/_app/billing')({
|
|
35
|
-
beforeLoad: visibility
|
|
36
|
+
beforeLoad: guardRoute(visibility),
|
|
36
37
|
component: Billing,
|
|
37
38
|
})
|
|
38
39
|
|
|
@@ -41,12 +42,13 @@ function Billing() {
|
|
|
41
42
|
}
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
The route's rule is one `defineVisibility` call at module scope, with an inline object literal as its argument.
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
Four constraints on that shape, because Softr reads the file rather than running it:
|
|
47
48
|
|
|
48
49
|
- The call must initialise a top-level `const`.
|
|
49
50
|
- The import must be a direct named import from `@softr/sdk`.
|
|
51
|
+
- The route guards itself in `beforeLoad`, naming a rule declared in the same file. A rule imported from elsewhere is rejected.
|
|
50
52
|
- Never declare on `__root.tsx`. The root cannot carry an audience, so a declaration there would gate nothing while looking like it does. Softr rejects it. Put the rule on a layout below the root instead.
|
|
51
53
|
|
|
52
54
|
The four rules:
|
|
@@ -70,28 +72,28 @@ Absent or empty `userGroupIds` means **any** signed-in visitor, not "nobody".
|
|
|
70
72
|
```tsx
|
|
71
73
|
// src/routes/_app.tsx: guards every route under /_app
|
|
72
74
|
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
|
73
|
-
import { defineVisibility } from '@softr/sdk'
|
|
75
|
+
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
74
76
|
|
|
75
77
|
const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
|
|
76
78
|
|
|
77
79
|
export const Route = createFileRoute('/_app')({
|
|
78
|
-
beforeLoad: visibility
|
|
80
|
+
beforeLoad: guardRoute(visibility),
|
|
79
81
|
component: () => <Outlet />,
|
|
80
82
|
})
|
|
81
83
|
```
|
|
82
84
|
|
|
83
|
-
The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists.
|
|
85
|
+
The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists. A route below a guarded layout declares and guards its own rule too.
|
|
84
86
|
|
|
85
87
|
A thrown error renders in place of the guarded route's component. A shell layout guarded with `LOGGED_IN_USERS`, where `unauthenticated` redirects, sends a signed-out visitor away before anything renders there. A rule that can *throw* — a `userGroupIds` rule with `forbidden` set to throw — should not sit on the shell itself, or the error replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the error renders inside the chrome.
|
|
86
88
|
|
|
87
89
|
### Change what a denied visitor gets
|
|
88
90
|
|
|
89
|
-
`
|
|
91
|
+
`guardRoute` takes per-reason overrides as a second argument. A bare string is shorthand for a redirect.
|
|
90
92
|
|
|
91
93
|
```ts
|
|
92
|
-
beforeLoad: visibility
|
|
93
|
-
beforeLoad: visibility
|
|
94
|
-
beforeLoad: visibility
|
|
94
|
+
beforeLoad: guardRoute(visibility, { unauthenticated: '/marketing/sign-up' }),
|
|
95
|
+
beforeLoad: guardRoute(visibility, { forbidden: { strategy: 'throw' } }),
|
|
96
|
+
beforeLoad: guardRoute(visibility, {
|
|
95
97
|
unauthenticated: { strategy: 'redirect', to: '/marketing/sign-up' },
|
|
96
98
|
forbidden: { strategy: 'throw' },
|
|
97
99
|
}),
|
|
@@ -122,13 +124,13 @@ All three reasons are required, because nothing fills in a reason the file leave
|
|
|
122
124
|
| `forbidden` | Signed in, in none of the named groups |
|
|
123
125
|
| `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
|
|
124
126
|
|
|
125
|
-
That file covers every route at once; a route's own
|
|
127
|
+
That file covers every route at once; a route's own overrides win over it. `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
|
|
126
128
|
|
|
127
129
|
### Render the error in place
|
|
128
130
|
|
|
129
131
|
```tsx
|
|
130
132
|
import { createFileRoute } from '@tanstack/react-router'
|
|
131
|
-
import { defineVisibility, isAccessError, isForbiddenError } from '@softr/sdk'
|
|
133
|
+
import { defineVisibility, guardRoute, isAccessError, isForbiddenError } from '@softr/sdk'
|
|
132
134
|
|
|
133
135
|
const visibility = defineVisibility({
|
|
134
136
|
userGroup: 'LOGGED_IN_USERS',
|
|
@@ -136,7 +138,7 @@ const visibility = defineVisibility({
|
|
|
136
138
|
})
|
|
137
139
|
|
|
138
140
|
export const Route = createFileRoute('/_app/billing')({
|
|
139
|
-
beforeLoad: visibility
|
|
141
|
+
beforeLoad: guardRoute(visibility),
|
|
140
142
|
component: Billing,
|
|
141
143
|
errorComponent: ({ error }: { error: unknown }) => {
|
|
142
144
|
if (isForbiddenError(error)) return <UpgradePrompt />
|
|
@@ -152,24 +154,27 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
|
|
|
152
154
|
|
|
153
155
|
### Run the check and then keep going
|
|
154
156
|
|
|
155
|
-
`
|
|
157
|
+
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility, context)` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
|
|
156
158
|
|
|
157
|
-
|
|
159
|
+
It also narrows `context.user` for the rest of the handler: `User` under `LOGGED_IN_USERS`, `null` under `NON_LOGGED_IN_USERS`. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
|
|
158
160
|
|
|
159
161
|
```ts
|
|
160
162
|
import { redirect } from '@tanstack/react-router'
|
|
163
|
+
import { assertRouteAccess } from '@softr/sdk'
|
|
161
164
|
|
|
162
165
|
beforeLoad: ({ context }) => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
+
assertRouteAccess(visibility, context)
|
|
167
|
+
if (context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
|
|
166
168
|
},
|
|
167
169
|
```
|
|
168
170
|
|
|
169
|
-
Reach for `
|
|
171
|
+
Reach for `assertRouteAccess` when the route wants the error thrown for it, `isAuthorized(visibility, context)` when it wants the same narrowing without a throw, and `checkAccess(visibility, context)` when it needs to branch on why. `checkAccess` returns `{ ok: true } | { ok: false, reason }` and throws nothing. All of them are synchronous and make no request.
|
|
172
|
+
|
|
173
|
+
`guardRoute` and `assertRouteAccess` throw router redirects, so they belong in a route and nowhere else. That is what `Route` in their names marks. `isAuthorized` and `checkAccess` throw nothing and answer anywhere, in a loader, a component or a server function.
|
|
170
174
|
|
|
171
175
|
## References
|
|
172
176
|
|
|
173
177
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a guard has just admitted
|
|
174
178
|
- [record-queries/SKILL.md](../record-queries/SKILL.md): a guard covers the page, not the server function behind it
|
|
175
179
|
- [auth/SKILL.md](../auth/SKILL.md): `NON_LOGGED_IN_USERS` and the sign-in flow it fronts
|
|
180
|
+
- [region-visibility/SKILL.md](../region-visibility/SKILL.md): showing part of a page to some of the visitors the route admits
|
|
@@ -9,10 +9,10 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.45
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
15
|
+
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
16
16
|
|
|
17
17
|
# Triggering workflows
|
|
18
18
|
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
2
|
-
import { SoftrContext } from "../auth/context.js";
|
|
3
|
-
|
|
4
|
-
//#region src/routes/visibility.d.ts
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Who may see a route.
|
|
8
|
-
*
|
|
9
|
-
* - `ALL_USERS` — everyone, signed in or not.
|
|
10
|
-
* - `NON_LOGGED_IN_USERS` — signed-out visitors only.
|
|
11
|
-
* - `LOGGED_IN_USERS` — signed-in visitors; with `userGroupIds`, only members of
|
|
12
|
-
* at least one of those groups.
|
|
13
|
-
*/
|
|
14
|
-
type RouteVisibility = {
|
|
15
|
-
userGroup: "ALL_USERS";
|
|
16
|
-
} | {
|
|
17
|
-
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
-
} | {
|
|
19
|
-
userGroup: "LOGGED_IN_USERS";
|
|
20
|
-
userGroupIds?: Array<SoftrUserGroupId>;
|
|
21
|
-
};
|
|
22
|
-
/**
|
|
23
|
-
* Why a visitor was denied.
|
|
24
|
-
*
|
|
25
|
-
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
26
|
-
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
27
|
-
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
28
|
-
*/
|
|
29
|
-
declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
|
|
30
|
-
type AccessReason = (typeof ACCESS_REASONS)[number];
|
|
31
|
-
/**
|
|
32
|
-
* What a guard does when it refuses a visitor: redirect to a path, or throw an
|
|
33
|
-
* {@link AccessError} for the route's `errorComponent` to render. A bare string
|
|
34
|
-
* is shorthand for redirecting there.
|
|
35
|
-
*/
|
|
36
|
-
type AccessBehaviour = string | {
|
|
37
|
-
strategy: "redirect";
|
|
38
|
-
to: string;
|
|
39
|
-
} | {
|
|
40
|
-
strategy: "throw";
|
|
41
|
-
};
|
|
42
|
-
/** Per-reason overrides of the default {@link AccessBehaviour}. */
|
|
43
|
-
type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
|
|
44
|
-
/** The result of {@link VisibilityHandle.check}: passed, or denied with a reason. */
|
|
45
|
-
type AccessCheck = {
|
|
46
|
-
ok: true;
|
|
47
|
-
} | {
|
|
48
|
-
ok: false;
|
|
49
|
-
reason: AccessReason;
|
|
50
|
-
};
|
|
51
|
-
/** The one field of {@link SoftrContext} every access check reads. */
|
|
52
|
-
type AccessContext = Pick<SoftrContext, "user">;
|
|
53
|
-
/** Decides whether a visitor passes a visibility rule, without throwing. */
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The access error a guard throws. It is plain data, not an `Error` subclass, so
|
|
57
|
-
* take it as `unknown` in an `errorComponent` and narrow it with
|
|
58
|
-
* {@link isAccessError}. It carries the reason and nothing else: the error is
|
|
59
|
-
* serialized into the page, so it never names the groups the route wanted.
|
|
60
|
-
*
|
|
61
|
-
* @example
|
|
62
|
-
* errorComponent: ({ error }: { error: unknown }) =>
|
|
63
|
-
* isAccessError(error) ? <NoAccess /> : <AppError />
|
|
64
|
-
*/
|
|
65
|
-
type AccessError = {
|
|
66
|
-
type: "access_error";
|
|
67
|
-
reason: AccessReason;
|
|
68
|
-
};
|
|
69
|
-
/**
|
|
70
|
-
* True for any access error thrown by a guard, whatever its reason.
|
|
71
|
-
*
|
|
72
|
-
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
73
|
-
* anything else at all — so every field is checked before it is narrowed.
|
|
74
|
-
*/
|
|
75
|
-
declare function isAccessError(error: unknown): error is AccessError;
|
|
76
|
-
/** True for an access error thrown because nobody was signed in. */
|
|
77
|
-
declare function isUnauthenticatedError(error: unknown): error is AccessError;
|
|
78
|
-
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
79
|
-
declare function isForbiddenError(error: unknown): error is AccessError;
|
|
80
|
-
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
81
|
-
declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
|
|
82
|
-
type Narrowed<R extends RouteVisibility> = R extends {
|
|
83
|
-
userGroup: "LOGGED_IN_USERS";
|
|
84
|
-
} ? AccessContext & {
|
|
85
|
-
user: User;
|
|
86
|
-
} : R extends {
|
|
87
|
-
userGroup: "NON_LOGGED_IN_USERS";
|
|
88
|
-
} ? AccessContext & {
|
|
89
|
-
user: null;
|
|
90
|
-
} : AccessContext;
|
|
91
|
-
/** The four ways to apply a rule, returned by {@link defineVisibility}. */
|
|
92
|
-
type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
|
|
93
|
-
/**
|
|
94
|
-
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
95
|
-
*
|
|
96
|
-
* By default an unauthenticated visitor is redirected to `/login`, an already
|
|
97
|
-
* signed-in one to `/`, and a forbidden one gets an {@link AccessError} thrown
|
|
98
|
-
* in place, for the route's `errorComponent` to render. Pass `overrides` to
|
|
99
|
-
* change what any one reason does.
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* beforeLoad: visibility.guard({ unauthenticated: "/sign-in" })
|
|
103
|
-
*/
|
|
104
|
-
guard(overrides?: GuardOverrides): (opts: {
|
|
105
|
-
context: AccessContext;
|
|
106
|
-
}) => void;
|
|
107
|
-
/**
|
|
108
|
-
* Throws the same access error or redirect `guard()` would, for code that
|
|
109
|
-
* needs to run its own checks after the rule.
|
|
110
|
-
*
|
|
111
|
-
* @example
|
|
112
|
-
* beforeLoad: ({ context }) => {
|
|
113
|
-
* visibility.assert(context)
|
|
114
|
-
* if (context.user.trialExpired) throw redirect({ to: "/renew" })
|
|
115
|
-
* }
|
|
116
|
-
*/
|
|
117
|
-
assert(context: AccessContext): void;
|
|
118
|
-
/**
|
|
119
|
-
* Whether the visitor passes the rule. Narrows `context.user` where the rule
|
|
120
|
-
* allows it.
|
|
121
|
-
*/
|
|
122
|
-
isAuthorized(context: AccessContext): context is Narrowed<R>;
|
|
123
|
-
/** The decision and, when a visitor is refused, the reason. Throws nothing. */
|
|
124
|
-
check(context: AccessContext): AccessCheck;
|
|
125
|
-
};
|
|
126
|
-
/**
|
|
127
|
-
* Declares who may see a route, and returns the handle that enforces it.
|
|
128
|
-
*
|
|
129
|
-
* One call per route file, at module scope: the build reads these declarations
|
|
130
|
-
* to publish the app's route visibility, so the rule must be a literal.
|
|
131
|
-
*
|
|
132
|
-
* @example
|
|
133
|
-
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
134
|
-
*
|
|
135
|
-
* export const Route = createFileRoute("/about")({
|
|
136
|
-
* beforeLoad: visibility.guard(),
|
|
137
|
-
* component: About,
|
|
138
|
-
* })
|
|
139
|
-
*/
|
|
140
|
-
declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
|
|
141
|
-
//#endregion
|
|
142
|
-
export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { panic } from "../assertions.js";
|
|
2
|
-
import { redirect } from "@tanstack/react-router";
|
|
3
|
-
|
|
4
|
-
//#region src/routes/visibility.ts
|
|
5
|
-
/**
|
|
6
|
-
* Why a visitor was denied.
|
|
7
|
-
*
|
|
8
|
-
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
9
|
-
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
10
|
-
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
11
|
-
*/
|
|
12
|
-
const ACCESS_REASONS = [
|
|
13
|
-
"unauthenticated",
|
|
14
|
-
"forbidden",
|
|
15
|
-
"alreadyAuthenticated"
|
|
16
|
-
];
|
|
17
|
-
/** Decides whether a visitor passes a visibility rule, without throwing. */
|
|
18
|
-
function checkAccess(rule, context) {
|
|
19
|
-
const { user } = context;
|
|
20
|
-
switch (rule.userGroup) {
|
|
21
|
-
case "ALL_USERS": return { ok: true };
|
|
22
|
-
case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
|
|
23
|
-
ok: false,
|
|
24
|
-
reason: "alreadyAuthenticated"
|
|
25
|
-
};
|
|
26
|
-
case "LOGGED_IN_USERS": {
|
|
27
|
-
if (user === null) return {
|
|
28
|
-
ok: false,
|
|
29
|
-
reason: "unauthenticated"
|
|
30
|
-
};
|
|
31
|
-
const required = rule.userGroupIds ?? [];
|
|
32
|
-
if (required.length === 0) return { ok: true };
|
|
33
|
-
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
34
|
-
ok: false,
|
|
35
|
-
reason: "forbidden"
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
function isAccessReason(value) {
|
|
41
|
-
return ACCESS_REASONS.some((reason) => reason === value);
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* True for any access error thrown by a guard, whatever its reason.
|
|
45
|
-
*
|
|
46
|
-
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
47
|
-
* anything else at all — so every field is checked before it is narrowed.
|
|
48
|
-
*/
|
|
49
|
-
function isAccessError(error) {
|
|
50
|
-
if (typeof error !== "object" || error === null) return false;
|
|
51
|
-
const candidate = error;
|
|
52
|
-
if (candidate.type !== "access_error") return false;
|
|
53
|
-
return isAccessReason(candidate.reason);
|
|
54
|
-
}
|
|
55
|
-
function isReason(error, reason) {
|
|
56
|
-
return isAccessError(error) && error.reason === reason;
|
|
57
|
-
}
|
|
58
|
-
/** True for an access error thrown because nobody was signed in. */
|
|
59
|
-
function isUnauthenticatedError(error) {
|
|
60
|
-
return isReason(error, "unauthenticated");
|
|
61
|
-
}
|
|
62
|
-
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
63
|
-
function isForbiddenError(error) {
|
|
64
|
-
return isReason(error, "forbidden");
|
|
65
|
-
}
|
|
66
|
-
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
67
|
-
function isAlreadyAuthenticatedError(error) {
|
|
68
|
-
return isReason(error, "alreadyAuthenticated");
|
|
69
|
-
}
|
|
70
|
-
/** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
|
|
71
|
-
function thrownFor(reason, overrides) {
|
|
72
|
-
const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
|
|
73
|
-
const resolved = typeof behaviour === "string" ? {
|
|
74
|
-
strategy: "redirect",
|
|
75
|
-
to: behaviour
|
|
76
|
-
} : behaviour;
|
|
77
|
-
if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
|
|
78
|
-
return {
|
|
79
|
-
type: "access_error",
|
|
80
|
-
reason
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Declares who may see a route, and returns the handle that enforces it.
|
|
85
|
-
*
|
|
86
|
-
* One call per route file, at module scope: the build reads these declarations
|
|
87
|
-
* to publish the app's route visibility, so the rule must be a literal.
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
91
|
-
*
|
|
92
|
-
* export const Route = createFileRoute("/about")({
|
|
93
|
-
* beforeLoad: visibility.guard(),
|
|
94
|
-
* component: About,
|
|
95
|
-
* })
|
|
96
|
-
*/
|
|
97
|
-
function defineVisibility(rule) {
|
|
98
|
-
return {
|
|
99
|
-
guard: (overrides) => ({ context }) => {
|
|
100
|
-
const result = checkAccess(rule, context);
|
|
101
|
-
if (result.ok) return;
|
|
102
|
-
throw thrownFor(result.reason, overrides);
|
|
103
|
-
},
|
|
104
|
-
assert: (context) => {
|
|
105
|
-
const result = checkAccess(rule, context);
|
|
106
|
-
if (result.ok) return;
|
|
107
|
-
throw thrownFor(result.reason, void 0);
|
|
108
|
-
},
|
|
109
|
-
isAuthorized: (context) => checkAccess(rule, context).ok,
|
|
110
|
-
check: (context) => checkAccess(rule, context)
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
//#endregion
|
|
115
|
-
export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|