@lobb-js/lobb-ext-auth 0.10.4 → 0.11.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/README.md +1 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +23 -4
- package/dist/index.js +41 -2
- package/dist/lib/components/pages/settings/index.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/users.svelte +1 -1
- package/dist/lib/components/pages/userSettings/index.svelte +45 -32
- package/dist/onStartup.js +17 -2
- package/extensions/auth/collections/collections.ts +2 -0
- package/extensions/auth/collections/shares.ts +60 -0
- package/extensions/auth/config/extensionConfigSchema.d.ts +41 -0
- package/extensions/auth/config/permissionsAction/create.d.ts +18 -0
- package/extensions/auth/config/permissionsAction/delete.d.ts +3 -0
- package/extensions/auth/config/permissionsAction/read.d.ts +11 -0
- package/extensions/auth/config/permissionsAction/update.d.ts +18 -0
- package/extensions/auth/index.ts +0 -2
- package/extensions/auth/permissions.d.ts +2 -0
- package/extensions/auth/permissions.ts +34 -0
- package/extensions/auth/studio/auth.ts +25 -5
- package/extensions/auth/studio/index.ts +44 -2
- package/extensions/auth/studio/lib/components/pages/settings/index.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/users.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/userSettings/index.svelte +45 -32
- package/extensions/auth/studio/onStartup.ts +14 -2
- package/extensions/auth/tests/collections/shares.test.ts +657 -0
- package/extensions/auth/tests/configs/auth.ts +17 -0
- package/extensions/auth/tests/controllers/me.test.ts +104 -0
- package/extensions/auth/tests/permissions.test.ts +127 -0
- package/extensions/auth/tests/workflows/shareIntersection.test.ts +158 -0
- package/extensions/auth/workflows/baseWorkflow.ts +48 -26
- package/extensions/auth/workflows/currentUserPermissionsWorkflow.ts +32 -0
- package/extensions/auth/workflows/index.ts +12 -0
- package/extensions/auth/workflows/meAliasWorkflows.ts +26 -0
- package/extensions/auth/workflows/policiesWorkflows.ts +64 -117
- package/extensions/auth/workflows/shareIntersection.ts +64 -0
- package/extensions/auth/workflows/sharesWorkflows.ts +135 -0
- package/extensions/auth/workflows/utils.ts +132 -224
- package/package.json +4 -6
- package/dist/lib/components/pages/userSettings/components/account.svelte +0 -106
- package/dist/lib/components/pages/userSettings/components/account.svelte.d.ts +0 -14
- package/dist/lib/components/pages/userSettings/components/profile.svelte +0 -87
- package/dist/lib/components/pages/userSettings/components/profile.svelte.d.ts +0 -14
- package/dist/tests/login.spec.d.ts +0 -1
- package/dist/tests/login.spec.js +0 -27
- package/dist/tests/package.json +0 -1
- package/dist/tests/playwright.config.cjs +0 -27
- package/dist/tests/playwright.config.d.cts +0 -2
- package/extensions/auth/studio/lib/components/pages/userSettings/components/account.svelte +0 -106
- package/extensions/auth/studio/lib/components/pages/userSettings/components/profile.svelte +0 -87
- package/extensions/auth/studio/tests/login.spec.ts +0 -34
- package/extensions/auth/studio/tests/package.json +0 -1
- package/extensions/auth/studio/tests/playwright.config.cjs +0 -27
package/README.md
CHANGED
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ExtensionUtils } from "@lobb-js/studio";
|
|
2
2
|
interface LoginPayload {
|
|
3
3
|
email: string;
|
|
4
4
|
password: string;
|
|
@@ -9,5 +9,6 @@ export declare class Auth {
|
|
|
9
9
|
getSession(): any;
|
|
10
10
|
login(payload: LoginPayload): Promise<void>;
|
|
11
11
|
logout(): Promise<void>;
|
|
12
|
+
fetchMe(): Promise<void>;
|
|
12
13
|
}
|
|
13
14
|
export {};
|
package/dist/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remountStudio } from "@lobb-js/studio";
|
|
1
2
|
export class Auth {
|
|
2
3
|
utils;
|
|
3
4
|
constructor(utils) {
|
|
@@ -27,12 +28,16 @@ export class Auth {
|
|
|
27
28
|
const result = await response.json();
|
|
28
29
|
const session = result.data;
|
|
29
30
|
localStorage.setItem("lobb_session", JSON.stringify(session));
|
|
30
|
-
this.utils.ctx.extensions.auth.session = session;
|
|
31
31
|
this.utils.lobb.setHeaders({
|
|
32
32
|
Authorization: `Bearer ${session.access_token.token}`,
|
|
33
33
|
});
|
|
34
34
|
const redirectTo = new URLSearchParams(window.location.search).get("redirect");
|
|
35
|
-
this.utils.
|
|
35
|
+
this.utils.goto(redirectTo || "/studio");
|
|
36
|
+
// Remount the Studio so onStartup re-runs and populates ctx with the
|
|
37
|
+
// freshly logged-in user's /me data. Everything inside Studio (sidebar,
|
|
38
|
+
// extensions, etc.) then initialises against the new session without
|
|
39
|
+
// needing per-component reactivity on auth state.
|
|
40
|
+
remountStudio();
|
|
36
41
|
}
|
|
37
42
|
async logout() {
|
|
38
43
|
await this.utils.lobb.request({
|
|
@@ -43,10 +48,24 @@ export class Auth {
|
|
|
43
48
|
const currentPath = window.location.pathname;
|
|
44
49
|
const loginPath = "/studio/extensions/auth/login_page";
|
|
45
50
|
if (currentPath !== loginPath && currentPath !== "/studio") {
|
|
46
|
-
this.utils.
|
|
51
|
+
this.utils.goto(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`);
|
|
47
52
|
}
|
|
48
53
|
else {
|
|
49
|
-
this.utils.
|
|
54
|
+
this.utils.goto(loginPath);
|
|
50
55
|
}
|
|
56
|
+
// Remount so onStartup re-runs against the cleared session — clears any
|
|
57
|
+
// stale ctx.extensions.auth.user / permissions from the previous user.
|
|
58
|
+
remountStudio();
|
|
59
|
+
}
|
|
60
|
+
async fetchMe() {
|
|
61
|
+
const response = await this.utils.lobb.request({
|
|
62
|
+
method: "GET",
|
|
63
|
+
route: "/api/collections/auth_users/me",
|
|
64
|
+
});
|
|
65
|
+
if (response.status >= 400)
|
|
66
|
+
throw new Error("Failed to fetch /me");
|
|
67
|
+
const result = await response.json();
|
|
68
|
+
this.utils.ctx.extensions.auth.user = result.data;
|
|
69
|
+
this.utils.ctx.extensions.auth.permissions = result.permissions ?? {};
|
|
51
70
|
}
|
|
52
71
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { onStartup, onRouteChange } from "./onStartup";
|
|
|
3
3
|
import LoginPage from "./lib/components/pages/loginPage/index.svelte";
|
|
4
4
|
import UserSettings from "./lib/components/pages/userSettings/index.svelte";
|
|
5
5
|
import Settings from "./lib/components/pages/settings/index.svelte";
|
|
6
|
+
import { isActionAllowed } from "../permissions";
|
|
6
7
|
// TODO we should export the extension object directly without a function at all. because we dont set configuration in here
|
|
7
8
|
export default function extension(utils) {
|
|
8
9
|
return {
|
|
@@ -14,12 +15,50 @@ export default function extension(utils) {
|
|
|
14
15
|
"pages.user_settings": UserSettings,
|
|
15
16
|
"pages.settings": Settings,
|
|
16
17
|
},
|
|
18
|
+
workflows: [
|
|
19
|
+
{
|
|
20
|
+
// Generic auth primitive: callers ask "can the current user access X?"
|
|
21
|
+
// and pass any combination of { collection, action, role }. The handler
|
|
22
|
+
// returns a boolean directly; callers read that and decide what to render.
|
|
23
|
+
// Note: returning a boolean means downstream handlers can't read the
|
|
24
|
+
// original request — if you ever need multiple extensions to influence
|
|
25
|
+
// this decision, switch back to a `{ ...input, allowed }` object shape.
|
|
26
|
+
eventName: "auth.canAccess",
|
|
27
|
+
handler: async (input) => {
|
|
28
|
+
// Admin shortcut — admins bypass everything, regardless of how
|
|
29
|
+
// their permissions snapshot was populated.
|
|
30
|
+
const user = utils.ctx.extensions.auth?.user;
|
|
31
|
+
if (user?.role === "admin")
|
|
32
|
+
return true;
|
|
33
|
+
// Walk the permissions snapshot. Works for both logged-in non-admin
|
|
34
|
+
// users (snapshot from their role) and share-token recipients
|
|
35
|
+
// (snapshot from the share's embedded permissions). If nothing
|
|
36
|
+
// is populated — anonymous viewer — default to deny.
|
|
37
|
+
const permissions = utils.ctx.extensions.auth?.permissions;
|
|
38
|
+
if (permissions === undefined || permissions === null)
|
|
39
|
+
return false;
|
|
40
|
+
if (permissions === true)
|
|
41
|
+
return true;
|
|
42
|
+
const action = input.action ?? "read";
|
|
43
|
+
const requiredCollections = Array.isArray(input.collection)
|
|
44
|
+
? input.collection
|
|
45
|
+
: input.collection ? [input.collection] : [];
|
|
46
|
+
for (const collectionName of requiredCollections) {
|
|
47
|
+
if (!isActionAllowed(collectionName, action, permissions)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
17
55
|
dashboardNavs: {
|
|
18
56
|
middle: [
|
|
19
57
|
{
|
|
20
58
|
label: "Auth",
|
|
21
59
|
icon: utils.components.Icons.Key,
|
|
22
60
|
href: "/studio/extensions/auth/settings/users",
|
|
61
|
+
represents: "auth_users",
|
|
23
62
|
},
|
|
24
63
|
],
|
|
25
64
|
bottom: [
|
|
@@ -27,14 +66,14 @@ export default function extension(utils) {
|
|
|
27
66
|
label: "My User",
|
|
28
67
|
icon: utils.components.Icons.User,
|
|
29
68
|
onclick: () => {
|
|
30
|
-
utils.
|
|
69
|
+
utils.goto("/studio/extensions/auth/user_settings");
|
|
31
70
|
},
|
|
32
71
|
navs: [
|
|
33
72
|
{
|
|
34
73
|
label: "User Settings",
|
|
35
74
|
icon: utils.components.Icons.Settings,
|
|
36
75
|
onclick: () => {
|
|
37
|
-
utils.
|
|
76
|
+
utils.goto("/studio/extensions/auth/user_settings");
|
|
38
77
|
},
|
|
39
78
|
},
|
|
40
79
|
{
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const components = props.utils.components;
|
|
9
9
|
const { Sidebar } = components;
|
|
10
10
|
const { Icons } = components;
|
|
11
|
-
const auth_settings_page = $derived(props.utils.
|
|
11
|
+
const auth_settings_page = $derived(props.utils.page.url.pathname.split("/")[5]);
|
|
12
12
|
const isSmall = $derived(!props.utils.mediaQueries.sm.current);
|
|
13
13
|
</script>
|
|
14
14
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
15
|
<components.Separator />
|
|
16
|
-
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted
|
|
16
|
+
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted-soft">
|
|
17
17
|
<components.DataTable
|
|
18
18
|
collectionName="auth_activity_feed"
|
|
19
19
|
/>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
15
|
<components.Separator />
|
|
16
|
-
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted
|
|
16
|
+
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted-soft">
|
|
17
17
|
<components.DataTable
|
|
18
18
|
collectionName="auth_roles"
|
|
19
19
|
/>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
15
|
<components.Separator />
|
|
16
|
-
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted
|
|
16
|
+
<div class="flex gap-4 border rounded-md overflow-hidden h-full bg-muted-soft">
|
|
17
17
|
<components.DataTable
|
|
18
18
|
collectionName="auth_users"
|
|
19
19
|
/>
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { ExtensionProps } from "@lobb-js/studio";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import { DetailView } from "@lobb-js/studio";
|
|
4
|
+
import { onMount } from "svelte";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const isSmall = $derived(!props.utils.mediaQueries.sm.current);
|
|
6
|
+
const { utils }: ExtensionProps = $props();
|
|
7
|
+
const { Button, Separator, Skeleton, Icons } = utils.components;
|
|
9
8
|
|
|
10
|
-
let
|
|
9
|
+
let entry = $state<Record<string, any>>({});
|
|
10
|
+
let loading = $state(true);
|
|
11
|
+
|
|
12
|
+
onMount(async () => {
|
|
13
|
+
// /me is the only auth_users path a role without explicit auth_users
|
|
14
|
+
// read perms can reach (self-bypass at the policy layer).
|
|
15
|
+
const response = await utils.lobb.findOne("auth_users", "me");
|
|
16
|
+
const result = await response.json();
|
|
17
|
+
entry = result.data;
|
|
18
|
+
loading = false;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function handleSave() {
|
|
22
|
+
const response = await utils.lobb.updateOne("auth_users", "me", entry);
|
|
23
|
+
if (response.status >= 400) {
|
|
24
|
+
const result = await response.json();
|
|
25
|
+
utils.toast.error(result.message);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
utils.toast.success("Profile updated successfully.");
|
|
29
|
+
}
|
|
11
30
|
</script>
|
|
12
31
|
|
|
13
32
|
<div class="flex flex-col gap-4 p-4">
|
|
@@ -17,32 +36,26 @@
|
|
|
17
36
|
Manage your user settings.
|
|
18
37
|
</div>
|
|
19
38
|
</div>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
>
|
|
28
|
-
Profile
|
|
29
|
-
</components.Button>
|
|
30
|
-
<components.Button
|
|
31
|
-
onclick={() => (selectedView = "account")}
|
|
32
|
-
variant="ghost"
|
|
33
|
-
class="flex justify-start text-muted-foreground hover:underline"
|
|
34
|
-
>
|
|
35
|
-
Account
|
|
36
|
-
</components.Button>
|
|
39
|
+
<Separator />
|
|
40
|
+
|
|
41
|
+
{#if loading}
|
|
42
|
+
<div class="flex max-w-xl flex-col gap-2">
|
|
43
|
+
<Skeleton class="h-8 w-full" />
|
|
44
|
+
<Skeleton class="h-8 w-[80%]" />
|
|
45
|
+
<Skeleton class="h-8 w-[60%]" />
|
|
37
46
|
</div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
{:else}
|
|
48
|
+
<div class="max-w-xl">
|
|
49
|
+
<DetailView collectionName="auth_users" bind:entry />
|
|
50
|
+
<div class="px-4 pt-2">
|
|
51
|
+
<Button
|
|
52
|
+
class="self-start"
|
|
53
|
+
Icon={Icons.Pencil}
|
|
54
|
+
onclick={handleSave}
|
|
55
|
+
>
|
|
56
|
+
Save
|
|
57
|
+
</Button>
|
|
45
58
|
</div>
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
</div>
|
|
60
|
+
{/if}
|
|
48
61
|
</div>
|
package/dist/onStartup.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { Auth } from "./auth";
|
|
2
2
|
const loginPath = "/studio/extensions/auth/login_page";
|
|
3
|
+
const publicPagesPrefix = "/studio/public/";
|
|
3
4
|
function isPublicPath(path, publicPaths) {
|
|
4
|
-
|
|
5
|
+
// Anything under /studio/public/ is a publicPages.* route — registered by
|
|
6
|
+
// an extension and intentionally exposed without auth (e.g. share-recipient
|
|
7
|
+
// views). Falls through to the app-level publicPaths config for legacy
|
|
8
|
+
// overrides.
|
|
9
|
+
if (path === loginPath)
|
|
10
|
+
return true;
|
|
11
|
+
if (path.startsWith(publicPagesPrefix))
|
|
12
|
+
return true;
|
|
13
|
+
return publicPaths.some(p => path.startsWith(p));
|
|
5
14
|
}
|
|
6
15
|
export async function onStartup(utils) {
|
|
7
16
|
const auth = new Auth(utils);
|
|
@@ -18,6 +27,12 @@ export async function onStartup(utils) {
|
|
|
18
27
|
const session = auth.getSession();
|
|
19
28
|
if (session) {
|
|
20
29
|
utils.ctx.extensions.auth.session = session;
|
|
30
|
+
try {
|
|
31
|
+
await auth.fetchMe();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
await auth.logout();
|
|
35
|
+
}
|
|
21
36
|
return;
|
|
22
37
|
}
|
|
23
38
|
const publicPaths = utils.ctx.meta?.extensions?.auth?.studio?.publicPaths ?? [];
|
|
@@ -32,6 +47,6 @@ export function onRouteChange(utils, path) {
|
|
|
32
47
|
return;
|
|
33
48
|
const publicPaths = utils.ctx.meta?.extensions?.auth?.studio?.publicPaths ?? [];
|
|
34
49
|
if (!isPublicPath(path, publicPaths)) {
|
|
35
|
-
utils.
|
|
50
|
+
utils.goto(`${loginPath}?redirect=${encodeURIComponent(path)}`);
|
|
36
51
|
}
|
|
37
52
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CollectionConfig, Lobb } from "@lobb-js/core";
|
|
2
2
|
import { usersCollection } from "./users.ts";
|
|
3
3
|
import { sessionsCollection } from "./sessions.ts";
|
|
4
|
+
import { sharesCollection } from "./shares.ts";
|
|
4
5
|
import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
|
|
5
6
|
import { activityFeedCollection } from "./activityFeed.ts";
|
|
6
7
|
|
|
@@ -11,6 +12,7 @@ export function collections(
|
|
|
11
12
|
const collectionsSchemas: Record<string, CollectionConfig> = {};
|
|
12
13
|
collectionsSchemas["auth_users"] = usersCollection;
|
|
13
14
|
collectionsSchemas["auth_sessions"] = sessionsCollection;
|
|
15
|
+
collectionsSchemas["auth_shares"] = sharesCollection;
|
|
14
16
|
|
|
15
17
|
// Convert the role field to an enum based on configured roles
|
|
16
18
|
const roleNames = Object.keys(extensionConfig.roles ?? {}).filter(r => r !== "public");
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CollectionConfig } from "@lobb-js/core";
|
|
2
|
+
|
|
3
|
+
// A share is a bearer credential that grants its holder a specific snapshot
|
|
4
|
+
// of permissions until a fixed expiry. Unlike a session it isn't tied to a
|
|
5
|
+
// user identity — anyone with the token gets exactly the listed permissions.
|
|
6
|
+
//
|
|
7
|
+
// `permissions` is stored as text holding a JSON-serialised PermissionsConfig
|
|
8
|
+
// (same shape as a role's permissions in the auth extension config).
|
|
9
|
+
export const sharesCollection: CollectionConfig = {
|
|
10
|
+
indexes: {},
|
|
11
|
+
fields: {
|
|
12
|
+
id: {
|
|
13
|
+
type: "integer",
|
|
14
|
+
},
|
|
15
|
+
// Auto-generated server-side by the auth_generateShareToken workflow on
|
|
16
|
+
// create — clients should not supply this; the generated token comes
|
|
17
|
+
// back in the create response.
|
|
18
|
+
token: {
|
|
19
|
+
type: "string",
|
|
20
|
+
length: 255,
|
|
21
|
+
unique: true,
|
|
22
|
+
},
|
|
23
|
+
label: {
|
|
24
|
+
type: "string",
|
|
25
|
+
length: 255,
|
|
26
|
+
},
|
|
27
|
+
permissions: {
|
|
28
|
+
type: "text",
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
// Absolute expiry timestamp — the source of truth stored in the DB and
|
|
32
|
+
// used by every-request validity checks and the periodic cleanup.
|
|
33
|
+
// Required at the schema level; callers can pass `expires_in_seconds`
|
|
34
|
+
// instead, which the auth_normalizeShareExpiry workflow converts to
|
|
35
|
+
// this field at the service layer (before the store-level required
|
|
36
|
+
// check fires).
|
|
37
|
+
expires_at: {
|
|
38
|
+
type: "datetime",
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
// Convenience write-only field: instead of computing an absolute
|
|
42
|
+
// timestamp, callers can pass a duration in seconds. Declared as a
|
|
43
|
+
// virtual integer so it shows up in the OpenAPI schema for documentation
|
|
44
|
+
// but never gets a database column — the workflow converts it to
|
|
45
|
+
// expires_at and strips it before insert.
|
|
46
|
+
expires_in_seconds: {
|
|
47
|
+
type: "integer",
|
|
48
|
+
virtual: true,
|
|
49
|
+
ui: { hidden: true },
|
|
50
|
+
},
|
|
51
|
+
created_by: {
|
|
52
|
+
type: "integer",
|
|
53
|
+
references: {
|
|
54
|
+
collection: "auth_users",
|
|
55
|
+
field: "id",
|
|
56
|
+
},
|
|
57
|
+
required: true,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CollectionConfig } from "@lobb-js/core";
|
|
2
|
+
import type { CreatePermissionAction } from "./permissionsAction/create.ts";
|
|
3
|
+
import type { ReadPermissionAction } from "./permissionsAction/read.ts";
|
|
4
|
+
import type { UpdatePermissionAction } from "./permissionsAction/update.ts";
|
|
5
|
+
import type { DeletePermissionAction } from "./permissionsAction/delete.ts";
|
|
6
|
+
export interface User {
|
|
7
|
+
id: number;
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
role: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PermissionAction {
|
|
13
|
+
}
|
|
14
|
+
export type CollectionPermissionsActions = {
|
|
15
|
+
create?: true | CreatePermissionAction;
|
|
16
|
+
read?: true | ReadPermissionAction;
|
|
17
|
+
update?: true | UpdatePermissionAction;
|
|
18
|
+
delete?: true | DeletePermissionAction;
|
|
19
|
+
};
|
|
20
|
+
export type CollectionPermissionActionsKeys = keyof CollectionPermissionsActions;
|
|
21
|
+
export type CollectionPermissionsConfig = true | CollectionPermissionsActions;
|
|
22
|
+
export type PermissionsConfig = true | Record<string, CollectionPermissionsConfig | undefined>;
|
|
23
|
+
export type RolesConfig = {
|
|
24
|
+
permissions: PermissionsConfig;
|
|
25
|
+
};
|
|
26
|
+
export type ExtensionConfig = {
|
|
27
|
+
admin: {
|
|
28
|
+
password: string;
|
|
29
|
+
email: string;
|
|
30
|
+
[key: string]: any;
|
|
31
|
+
};
|
|
32
|
+
dashboard_access_roles?: string[];
|
|
33
|
+
roles?: Record<string, RolesConfig | undefined>;
|
|
34
|
+
extend_users?: {
|
|
35
|
+
indexes?: CollectionConfig["indexes"];
|
|
36
|
+
fields?: Omit<CollectionConfig["fields"], "id">;
|
|
37
|
+
};
|
|
38
|
+
studio?: {
|
|
39
|
+
publicPaths?: string[];
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PermissionAction, User } from "../extensionConfigSchema.ts";
|
|
2
|
+
interface FieldTrasnsformerFnParams {
|
|
3
|
+
value: unknown;
|
|
4
|
+
payload: Record<string, unknown>;
|
|
5
|
+
user?: User;
|
|
6
|
+
}
|
|
7
|
+
type FieldTrasnsformerFn = (params: FieldTrasnsformerFnParams) => unknown;
|
|
8
|
+
interface CreateGuardProps {
|
|
9
|
+
payload: Record<string, unknown>;
|
|
10
|
+
user?: User;
|
|
11
|
+
}
|
|
12
|
+
type CreateGuardFn = (props: CreateGuardProps) => true | void;
|
|
13
|
+
export interface CreatePermissionAction extends PermissionAction {
|
|
14
|
+
payloadGuard?: CreateGuardFn;
|
|
15
|
+
fields?: Record<string, true>;
|
|
16
|
+
mutate?: Record<string, FieldTrasnsformerFn>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Filter } from "@lobb-js/core";
|
|
2
|
+
import type { PermissionAction, User } from "../extensionConfigSchema.ts";
|
|
3
|
+
export interface ReadPermissionAction extends PermissionAction {
|
|
4
|
+
/**
|
|
5
|
+
* Filter that gets passed to the db select query condition
|
|
6
|
+
*/
|
|
7
|
+
filter?: Filter<{
|
|
8
|
+
user?: User;
|
|
9
|
+
}>;
|
|
10
|
+
fields?: Record<string, true>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PermissionAction, User } from "../extensionConfigSchema.ts";
|
|
2
|
+
interface FieldTrasnsformerFnParams {
|
|
3
|
+
value: unknown;
|
|
4
|
+
payload: Record<string, unknown>;
|
|
5
|
+
user?: User;
|
|
6
|
+
}
|
|
7
|
+
type FieldTrasnsformerFn = (params: FieldTrasnsformerFnParams) => unknown;
|
|
8
|
+
interface UpdateGuardProps {
|
|
9
|
+
payload: Record<string, unknown>;
|
|
10
|
+
user?: User;
|
|
11
|
+
}
|
|
12
|
+
type UpdateGuardFn = (props: UpdateGuardProps) => true | void;
|
|
13
|
+
export interface UpdatePermissionAction extends PermissionAction {
|
|
14
|
+
payloadGuard?: UpdateGuardFn;
|
|
15
|
+
fields?: Record<string, true>;
|
|
16
|
+
mutate?: Record<string, FieldTrasnsformerFn>;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
package/extensions/auth/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Extension } from "@lobb-js/core";
|
|
2
2
|
import type { ExtensionConfig } from "./config/extensionConfigSchema.ts";
|
|
3
3
|
|
|
4
|
-
import { init } from "./database/init.ts";
|
|
5
4
|
import { collections } from "./collections/collections.ts";
|
|
6
5
|
import { meta } from "./meta/meta.ts";
|
|
7
6
|
import { migrations } from "./database/migrations.ts";
|
|
@@ -11,7 +10,6 @@ export default function auth(extensionConfig: ExtensionConfig): Extension {
|
|
|
11
10
|
return {
|
|
12
11
|
name: "auth",
|
|
13
12
|
icon: "Key",
|
|
14
|
-
init: (lobb) => init(lobb, extensionConfig),
|
|
15
13
|
collections: (lobb) => collections(lobb, extensionConfig),
|
|
16
14
|
migrations: migrations,
|
|
17
15
|
meta: (lobb) => meta(lobb, extensionConfig),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CollectionPermissionActionsKeys,
|
|
3
|
+
PermissionsConfig,
|
|
4
|
+
} from "./config/extensionConfigSchema.ts";
|
|
5
|
+
|
|
6
|
+
// Pure permission check shared by the server-side handlePolicy and the
|
|
7
|
+
// studio's auth.canAccess workflow. Walks the permissions tree and returns
|
|
8
|
+
// a yes/no — server-side then layers on guards/filters/error-throwing,
|
|
9
|
+
// client-side uses the answer to decide whether to render UI.
|
|
10
|
+
//
|
|
11
|
+
// An object value for action permission means "conditional access" (filter
|
|
12
|
+
// and/or fields restrictions). For UI gating that counts as allowed; the
|
|
13
|
+
// server enforces the actual restrictions on real requests.
|
|
14
|
+
export function isActionAllowed(
|
|
15
|
+
collection: string,
|
|
16
|
+
action: CollectionPermissionActionsKeys,
|
|
17
|
+
permissions: PermissionsConfig | undefined,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (permissions === true) return true;
|
|
20
|
+
if (!permissions) return false;
|
|
21
|
+
const collPerm = permissions[collection];
|
|
22
|
+
if (collPerm === true) return true;
|
|
23
|
+
if (!collPerm) return false;
|
|
24
|
+
const actionPerm = collPerm[action];
|
|
25
|
+
if (actionPerm === true) return true;
|
|
26
|
+
// A conditional grant must actually list at least one constraint
|
|
27
|
+
// (filter / fields / payloadGuard / mutate). Empty `{}` collapses back
|
|
28
|
+
// to "no grant" — explicit `true` is required for unconditional access.
|
|
29
|
+
return (
|
|
30
|
+
typeof actionPerm === "object" &&
|
|
31
|
+
actionPerm !== null &&
|
|
32
|
+
Object.keys(actionPerm).length > 0
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { remountStudio, type ExtensionUtils } from "@lobb-js/studio";
|
|
2
2
|
|
|
3
3
|
interface LoginPayload {
|
|
4
4
|
email: string;
|
|
@@ -39,12 +39,18 @@ export class Auth {
|
|
|
39
39
|
const session = result.data;
|
|
40
40
|
localStorage.setItem("lobb_session", JSON.stringify(session));
|
|
41
41
|
|
|
42
|
-
this.utils.ctx.extensions.auth.session = session;
|
|
43
42
|
this.utils.lobb.setHeaders({
|
|
44
43
|
Authorization: `Bearer ${session.access_token.token}`,
|
|
45
44
|
});
|
|
45
|
+
|
|
46
46
|
const redirectTo = new URLSearchParams(window.location.search).get("redirect");
|
|
47
|
-
this.utils.
|
|
47
|
+
this.utils.goto(redirectTo || "/studio");
|
|
48
|
+
|
|
49
|
+
// Remount the Studio so onStartup re-runs and populates ctx with the
|
|
50
|
+
// freshly logged-in user's /me data. Everything inside Studio (sidebar,
|
|
51
|
+
// extensions, etc.) then initialises against the new session without
|
|
52
|
+
// needing per-component reactivity on auth state.
|
|
53
|
+
remountStudio();
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
public async logout() {
|
|
@@ -56,9 +62,23 @@ export class Auth {
|
|
|
56
62
|
const currentPath = window.location.pathname;
|
|
57
63
|
const loginPath = "/studio/extensions/auth/login_page";
|
|
58
64
|
if (currentPath !== loginPath && currentPath !== "/studio") {
|
|
59
|
-
this.utils.
|
|
65
|
+
this.utils.goto(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`);
|
|
60
66
|
} else {
|
|
61
|
-
this.utils.
|
|
67
|
+
this.utils.goto(loginPath);
|
|
62
68
|
}
|
|
69
|
+
// Remount so onStartup re-runs against the cleared session — clears any
|
|
70
|
+
// stale ctx.extensions.auth.user / permissions from the previous user.
|
|
71
|
+
remountStudio();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async fetchMe(): Promise<void> {
|
|
75
|
+
const response = await this.utils.lobb.request({
|
|
76
|
+
method: "GET",
|
|
77
|
+
route: "/api/collections/auth_users/me",
|
|
78
|
+
});
|
|
79
|
+
if (response.status >= 400) throw new Error("Failed to fetch /me");
|
|
80
|
+
const result = await response.json();
|
|
81
|
+
this.utils.ctx.extensions.auth.user = result.data;
|
|
82
|
+
this.utils.ctx.extensions.auth.permissions = result.permissions ?? {};
|
|
63
83
|
}
|
|
64
84
|
}
|