@protobase/server 0.21.12 → 0.22.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/better-auth/auth-mail/invitation-email.js +23 -27
- package/dist/better-auth/create-auth.d.ts +462 -24
- package/dist/better-auth/create-auth.js +12 -1
- package/dist/better-auth/invitations/account-from-invitation.js +50 -0
- package/dist/better-auth/invitations/invitation-endpoints.js +102 -0
- package/dist/better-auth/invitations/invitation-link-hook.js +34 -0
- package/dist/better-auth/invitations/invitation-of-token.js +24 -0
- package/dist/better-auth/{organizations → invitations}/invitation-token.js +7 -2
- package/dist/better-auth/last-admin-guard.js +62 -0
- package/dist/better-auth/last-admin.js +18 -0
- package/dist/better-auth/organizations/accept-invitation.js +4 -8
- package/dist/better-auth/organizations/invitation-endpoints.js +17 -114
- package/dist/better-auth/organizations/member-endpoints.js +36 -19
- package/dist/better-auth/organizations/options.js +2 -1
- package/dist/better-auth/organizations/organization-plugin.js +1 -1
- package/dist/better-auth/organizations/protobase-plugin.js +7 -9
- package/dist/better-auth/organizations/role-definitions.js +5 -4
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/roles/role-labels.js +14 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
- package/dist/better-auth/status-route.js +3 -8
- package/dist/better-auth/users/app-invitation-store.js +62 -0
- package/dist/better-auth/users/user-endpoints.js +79 -0
- package/dist/better-auth/users/users-plugin.js +22 -0
- package/dist/better-auth/users.js +1 -2
- package/dist/check-views.js +5 -3
- package/dist/index.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { noGlobalRole } from "../organizations/role-definitions.js";
|
|
2
|
+
import { findOrganization } from "../organizations/store.js";
|
|
3
|
+
import { acceptInvitation } from "../organizations/accept-invitation.js";
|
|
4
|
+
import { deleteAppInvitation } from "../users/app-invitation-store.js";
|
|
5
|
+
import { createInvitedAccount, signInInvitedAccount } from "./account-from-invitation.js";
|
|
6
|
+
import { invitationOfToken } from "./invitation-of-token.js";
|
|
7
|
+
import * as z$1 from "zod";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
//#region src/better-auth/invitations/invitation-endpoints.ts
|
|
10
|
+
const tokenQuery = z$1.object({ token: z$1.string().min(1) });
|
|
11
|
+
/**
|
|
12
|
+
* The endpoints an emailed invitation link opens, to the app or, with organizations, to one of them: what it is for,
|
|
13
|
+
* and creating an account from it for someone who has none. Open sign-up stays off; an invitation is an address someone
|
|
14
|
+
* vouched for, and the link in the mail proves the person reads it. Accepting an organization's invitation signed in is
|
|
15
|
+
* the organization plugin's.
|
|
16
|
+
*/
|
|
17
|
+
const invitationEndpoints = ({ mail, organizations }) => ({
|
|
18
|
+
/**
|
|
19
|
+
* `GET /invitation?token=`: who invites, the address and roles, until when, and whether the address has an account;
|
|
20
|
+
* for an invitation to the app also the app's name, and for an organization's the organization and the role there.
|
|
21
|
+
*/
|
|
22
|
+
getInvitation: createAuthEndpoint("/invitation", {
|
|
23
|
+
method: "GET",
|
|
24
|
+
query: tokenQuery
|
|
25
|
+
}, async (ctx) => {
|
|
26
|
+
const found = await invitationOfToken(ctx.context, ctx.query.token, { organizations: Boolean(organizations) });
|
|
27
|
+
const { invitation } = found;
|
|
28
|
+
const inviter = await ctx.context.internalAdapter.findUserById(invitation.inviterId);
|
|
29
|
+
const account = await ctx.context.internalAdapter.findUserByEmail(invitation.email.toLowerCase());
|
|
30
|
+
const common = {
|
|
31
|
+
inviter: { name: inviter?.name || inviter?.email || "" },
|
|
32
|
+
email: invitation.email,
|
|
33
|
+
expiresAt: new Date(invitation.expiresAt).toISOString(),
|
|
34
|
+
hasAccount: Boolean(account)
|
|
35
|
+
};
|
|
36
|
+
if (found.kind === "app") return ctx.json({
|
|
37
|
+
app: { name: ctx.context.appName },
|
|
38
|
+
...common,
|
|
39
|
+
appRoles: found.invitation.roles
|
|
40
|
+
});
|
|
41
|
+
const organization = await findOrganization(ctx.context.adapter, found.invitation.organizationId);
|
|
42
|
+
return ctx.json({
|
|
43
|
+
organization: {
|
|
44
|
+
id: found.invitation.organizationId,
|
|
45
|
+
name: organization?.name ?? ""
|
|
46
|
+
},
|
|
47
|
+
...common,
|
|
48
|
+
role: found.invitation.role,
|
|
49
|
+
appRoles: found.invitation.appRoles
|
|
50
|
+
});
|
|
51
|
+
}),
|
|
52
|
+
/**
|
|
53
|
+
* `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
|
|
54
|
+
* verified, and signs the person in. An invitation to the app gives the account its roles; one to an organization
|
|
55
|
+
* gives no global role, and accepts the membership, where the session then works.
|
|
56
|
+
*/
|
|
57
|
+
signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: z$1.object({
|
|
60
|
+
token: z$1.string().min(1),
|
|
61
|
+
name: z$1.string().trim().min(1).max(200),
|
|
62
|
+
password: z$1.string().optional()
|
|
63
|
+
})
|
|
64
|
+
}, async (ctx) => {
|
|
65
|
+
const found = await invitationOfToken(ctx.context, ctx.body.token, { organizations: Boolean(organizations) });
|
|
66
|
+
const { email } = found.invitation;
|
|
67
|
+
if (await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase())) {
|
|
68
|
+
const message = found.kind === "app" ? "This address has an account already: sign in." : "This address has an account already: sign in to accept the invitation.";
|
|
69
|
+
throw new APIError("BAD_REQUEST", {
|
|
70
|
+
code: "ACCOUNT_EXISTS",
|
|
71
|
+
message
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const account = {
|
|
75
|
+
email,
|
|
76
|
+
name: ctx.body.name,
|
|
77
|
+
...ctx.body.password !== void 0 && { password: ctx.body.password },
|
|
78
|
+
mail
|
|
79
|
+
};
|
|
80
|
+
if (found.kind === "app") {
|
|
81
|
+
const user = await createInvitedAccount(ctx, {
|
|
82
|
+
...account,
|
|
83
|
+
role: found.invitation.roles.join(",")
|
|
84
|
+
});
|
|
85
|
+
await deleteAppInvitation(ctx.context, found.invitation.id);
|
|
86
|
+
await signInInvitedAccount(ctx, user);
|
|
87
|
+
return ctx.json({ user: {
|
|
88
|
+
id: user.id,
|
|
89
|
+
email: user.email
|
|
90
|
+
} });
|
|
91
|
+
}
|
|
92
|
+
const user = await createInvitedAccount(ctx, {
|
|
93
|
+
...account,
|
|
94
|
+
role: noGlobalRole
|
|
95
|
+
});
|
|
96
|
+
const member = await acceptInvitation(ctx.context, found.invitation, user, organizations.membershipLimit);
|
|
97
|
+
await signInInvitedAccount(ctx, user, found.invitation.organizationId);
|
|
98
|
+
return ctx.json({ member });
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
//#endregion
|
|
102
|
+
export { invitationEndpoints };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
2
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
3
|
+
//#region src/better-auth/invitations/invitation-link-hook.ts
|
|
4
|
+
/**
|
|
5
|
+
* Without a mailer an invitation cannot be emailed, so whoever invites, at one of `paths`, gets its link in the answer,
|
|
6
|
+
* once, to pass on. The answer carries the invitation's `id` and `expiresAt`. Inviting again gives a new link, since it
|
|
7
|
+
* moves the expiry.
|
|
8
|
+
*/
|
|
9
|
+
const invitationLinkHook = (paths, invitationUrl) => ({
|
|
10
|
+
matcher: (context) => paths.includes(context.path ?? ""),
|
|
11
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
12
|
+
const answer = await withInvitationLink(ctx.context.returned, ctx.context.secret, invitationUrl);
|
|
13
|
+
if (answer) ctx.context.returned = answer;
|
|
14
|
+
})
|
|
15
|
+
});
|
|
16
|
+
/** An invitation's answer with its `link` added; `undefined` for an error or an answer without an invitation. */
|
|
17
|
+
const withInvitationLink = async (returned, secret, invitationUrl) => {
|
|
18
|
+
const answer = returned;
|
|
19
|
+
if (!answer || answer instanceof Response || answer instanceof Error || !answer.id || !answer.expiresAt) return void 0;
|
|
20
|
+
const token = await invitationToken(secret, {
|
|
21
|
+
id: answer.id,
|
|
22
|
+
expiresAt: new Date(answer.expiresAt)
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
...answer,
|
|
26
|
+
link: invitationLink(invitationUrl, token)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** The page an invitation link opens, with `?token=`, unless the app names another. */
|
|
30
|
+
const defaultInvitationUrl = (baseURL) => `${baseURL.replace(/\/$/, "")}/-/invitation`;
|
|
31
|
+
/** How long an invitation works unless the app says otherwise: 7 days, in seconds. */
|
|
32
|
+
const defaultInvitationSeconds = 604800;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { defaultInvitationSeconds, defaultInvitationUrl, invitationLinkHook, withInvitationLink };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
|
|
2
|
+
import { invitationOfToken as invitationOfToken$1 } from "../organizations/accept-invitation.js";
|
|
3
|
+
import { findAppInvitation } from "../users/app-invitation-store.js";
|
|
4
|
+
import { APIError } from "better-auth/api";
|
|
5
|
+
//#region src/better-auth/invitations/invitation-of-token.ts
|
|
6
|
+
/** The pending invitation an emailed link's token names, either kind, or a 400 `INVITATION_INVALID`. */
|
|
7
|
+
const invitationOfToken = async (context, token, { organizations }) => {
|
|
8
|
+
const id = invitationIdOf(token);
|
|
9
|
+
const app = id ? await findAppInvitation(context, id) : void 0;
|
|
10
|
+
if (app) {
|
|
11
|
+
if (!await verifyInvitationToken(context.secret, token, app)) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
12
|
+
return {
|
|
13
|
+
kind: "app",
|
|
14
|
+
invitation: app
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (!organizations) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
18
|
+
return {
|
|
19
|
+
kind: "organization",
|
|
20
|
+
invitation: await invitationOfToken$1(context, token)
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { invitationOfToken };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region src/better-auth/
|
|
1
|
+
//#region src/better-auth/invitations/invitation-token.ts
|
|
2
2
|
const encoder = new TextEncoder();
|
|
3
3
|
const base64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
4
4
|
const signature = async (secret, id, expiresAt) => {
|
|
@@ -23,5 +23,10 @@ const verifyInvitationToken = async (secret, token, invitation) => {
|
|
|
23
23
|
};
|
|
24
24
|
/** The link in the email: the app's invitation page with the token. */
|
|
25
25
|
const invitationLink = (pageUrl, token) => `${pageUrl}?token=${encodeURIComponent(token)}`;
|
|
26
|
+
/** Why a link's token finds no pending invitation, of either kind: the body of its 400. */
|
|
27
|
+
const invalidInvitation = {
|
|
28
|
+
code: "INVITATION_INVALID",
|
|
29
|
+
message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
|
|
30
|
+
};
|
|
26
31
|
//#endregion
|
|
27
|
-
export { invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
|
|
32
|
+
export { invalidInvitation, invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
import { wouldLeaveNoAdmin } from "./last-admin.js";
|
|
3
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
4
|
+
//#region src/better-auth/last-admin-guard.ts
|
|
5
|
+
const rolesOf = (role) => typeof role === "string" || Array.isArray(role) ? parseRoles(role) : void 0;
|
|
6
|
+
/** The change an admin endpoint makes to its user, from its path and body; `undefined` when it touches no role or block. */
|
|
7
|
+
const adminChange = (path, body) => {
|
|
8
|
+
if (typeof body.userId !== "string") return void 0;
|
|
9
|
+
const userId = body.userId;
|
|
10
|
+
if (path === "/admin/set-role") return {
|
|
11
|
+
userId,
|
|
12
|
+
roles: rolesOf(body.role) ?? []
|
|
13
|
+
};
|
|
14
|
+
if (path === "/admin/ban-user") return {
|
|
15
|
+
userId,
|
|
16
|
+
banned: true
|
|
17
|
+
};
|
|
18
|
+
if (path === "/admin/remove-user") return {
|
|
19
|
+
userId,
|
|
20
|
+
removed: true
|
|
21
|
+
};
|
|
22
|
+
if (path !== "/admin/update-user") return void 0;
|
|
23
|
+
const roles = rolesOf(body.data?.role);
|
|
24
|
+
const banned = typeof body.data?.banned === "boolean" ? body.data.banned : void 0;
|
|
25
|
+
if (roles === void 0 && banned === void 0) return void 0;
|
|
26
|
+
return {
|
|
27
|
+
userId,
|
|
28
|
+
...roles && { roles },
|
|
29
|
+
...banned !== void 0 && { banned }
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const guardedPaths = [
|
|
33
|
+
"/admin/set-role",
|
|
34
|
+
"/admin/ban-user",
|
|
35
|
+
"/admin/remove-user",
|
|
36
|
+
"/admin/update-user"
|
|
37
|
+
];
|
|
38
|
+
/** Refuses, with `400 LAST_ADMIN`, an admin endpoint that would demote, block or remove the last active admin. */
|
|
39
|
+
const lastAdminGuardPlugin = () => ({
|
|
40
|
+
id: "protobase-last-admin",
|
|
41
|
+
hooks: { before: [{
|
|
42
|
+
matcher: (ctx) => guardedPaths.includes(ctx.path ?? ""),
|
|
43
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
44
|
+
const change = adminChange(ctx.path, ctx.body ?? {});
|
|
45
|
+
if (!change) return;
|
|
46
|
+
const users = await ctx.context.adapter.findMany({
|
|
47
|
+
model: "user",
|
|
48
|
+
select: [
|
|
49
|
+
"id",
|
|
50
|
+
"role",
|
|
51
|
+
"banned"
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
if (wouldLeaveNoAdmin(users, change)) throw new APIError("BAD_REQUEST", {
|
|
55
|
+
code: "LAST_ADMIN",
|
|
56
|
+
message: "This is the last admin: make someone else an admin first."
|
|
57
|
+
});
|
|
58
|
+
})
|
|
59
|
+
}] }
|
|
60
|
+
});
|
|
61
|
+
//#endregion
|
|
62
|
+
export { adminChange, lastAdminGuardPlugin };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
//#region src/better-auth/last-admin.ts
|
|
3
|
+
/** Holds the admin role and is not blocked: the last of these can never be demoted, blocked or removed. */
|
|
4
|
+
const isActiveAdmin = (user) => parseRoles(user.role).includes("admin") && !user.banned;
|
|
5
|
+
/** Whether `change` would leave `users` without an active admin, while they have one now. */
|
|
6
|
+
const wouldLeaveNoAdmin = (users, change) => {
|
|
7
|
+
const target = users.find((user) => user.id === change.userId);
|
|
8
|
+
if (!target || !isActiveAdmin(target)) return false;
|
|
9
|
+
if (change.removed) return users.filter(isActiveAdmin).length <= 1;
|
|
10
|
+
const after = {
|
|
11
|
+
...target,
|
|
12
|
+
...change.roles && { role: change.roles },
|
|
13
|
+
...change.banned !== void 0 && { banned: change.banned }
|
|
14
|
+
};
|
|
15
|
+
return !isActiveAdmin(after) && users.filter(isActiveAdmin).length <= 1;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { isActiveAdmin, wouldLeaveNoAdmin };
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
+
import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "../invitations/invitation-token.js";
|
|
1
2
|
import { parseAppRoles } from "./app-roles.js";
|
|
2
3
|
import { findMember, membersOf } from "./store.js";
|
|
3
|
-
import { invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
|
|
4
4
|
import { APIError } from "better-auth/api";
|
|
5
5
|
//#region src/better-auth/organizations/accept-invitation.ts
|
|
6
|
-
const invalid = {
|
|
7
|
-
code: "INVITATION_INVALID",
|
|
8
|
-
message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
|
|
9
|
-
};
|
|
10
6
|
/** The pending invitation an emailed link's token names, or a 400 `INVITATION_INVALID`. */
|
|
11
7
|
const invitationOfToken = async (context, token) => {
|
|
12
8
|
const id = invitationIdOf(token);
|
|
@@ -17,8 +13,8 @@ const invitationOfToken = async (context, token) => {
|
|
|
17
13
|
value: id
|
|
18
14
|
}]
|
|
19
15
|
});
|
|
20
|
-
if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST",
|
|
21
|
-
if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST",
|
|
16
|
+
if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
17
|
+
if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
22
18
|
return {
|
|
23
19
|
...row,
|
|
24
20
|
appRoles: parseAppRoles(row.appRoles)
|
|
@@ -60,7 +56,7 @@ const acceptInvitation = async (context, invitation, user, membershipLimit) => {
|
|
|
60
56
|
value: "pending"
|
|
61
57
|
}],
|
|
62
58
|
update: { status: "accepted" }
|
|
63
|
-
})) throw new APIError("BAD_REQUEST",
|
|
59
|
+
})) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
64
60
|
return {
|
|
65
61
|
...await trx.create({
|
|
66
62
|
model: "member",
|
|
@@ -1,120 +1,23 @@
|
|
|
1
|
-
import { noGlobalRole } from "./role-definitions.js";
|
|
2
|
-
import { findOrganization } from "./store.js";
|
|
3
|
-
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
4
|
-
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
5
|
-
import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
6
|
-
import { readSignInPolicy } from "../policy-store.js";
|
|
7
1
|
import { acceptInvitation, invitationOfToken } from "./accept-invitation.js";
|
|
8
2
|
import * as z$1 from "zod";
|
|
9
|
-
import {
|
|
10
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
3
|
+
import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
|
11
4
|
//#region src/better-auth/organizations/invitation-endpoints.ts
|
|
12
|
-
const tokenBody = z$1.object({ token: z$1.string().min(1) });
|
|
13
|
-
const startIn = (context, sessionToken, organizationId) => context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
|
|
14
5
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the mail proves the person reads it.
|
|
6
|
+
* Accepting an organization's emailed invitation signed in. What a link is for, and making an account from it, are the
|
|
7
|
+
* shared invitation endpoints (`invitations/invitation-endpoints.ts`), which serve invitations to the app as well.
|
|
18
8
|
*/
|
|
19
|
-
const invitationEndpoints = ({ resolved
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
name: organization?.name ?? ""
|
|
33
|
-
},
|
|
34
|
-
inviter: { name: inviter?.name || inviter?.email || "" },
|
|
35
|
-
email: invitation.email,
|
|
36
|
-
role: invitation.role,
|
|
37
|
-
appRoles: invitation.appRoles,
|
|
38
|
-
expiresAt: new Date(invitation.expiresAt).toISOString(),
|
|
39
|
-
hasAccount: Boolean(account)
|
|
40
|
-
});
|
|
41
|
-
}),
|
|
42
|
-
/** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
|
|
43
|
-
acceptInvitation: createAuthEndpoint("/invitation/accept", {
|
|
44
|
-
method: "POST",
|
|
45
|
-
body: tokenBody,
|
|
46
|
-
use: [sessionMiddleware]
|
|
47
|
-
}, async (ctx) => {
|
|
48
|
-
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
49
|
-
const { user, session } = ctx.context.session;
|
|
50
|
-
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
51
|
-
await startIn(ctx.context, session.token, invitation.organizationId);
|
|
52
|
-
return ctx.json({ member });
|
|
53
|
-
}),
|
|
54
|
-
/**
|
|
55
|
-
* `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
|
|
56
|
-
* verified and with no global role, accepts the invitation and signs the person in there. A password is needed unless
|
|
57
|
-
* the sign-in policy offers emailed codes; the policy's required methods are then set up as after any sign-in.
|
|
58
|
-
*/
|
|
59
|
-
signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: z$1.object({
|
|
62
|
-
token: z$1.string().min(1),
|
|
63
|
-
name: z$1.string().trim().min(1).max(200),
|
|
64
|
-
password: z$1.string().optional()
|
|
65
|
-
})
|
|
66
|
-
}, async (ctx) => {
|
|
67
|
-
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
68
|
-
const { internalAdapter } = ctx.context;
|
|
69
|
-
if (await internalAdapter.findUserByEmail(invitation.email.toLowerCase())) throw new APIError("BAD_REQUEST", {
|
|
70
|
-
code: "ACCOUNT_EXISTS",
|
|
71
|
-
message: "This address has an account already: sign in to accept the invitation."
|
|
72
|
-
});
|
|
73
|
-
const { password } = ctx.body;
|
|
74
|
-
checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.context.adapter)).policy, { mail }), password);
|
|
75
|
-
const user = await internalAdapter.createUser({
|
|
76
|
-
email: invitation.email.toLowerCase(),
|
|
77
|
-
name: ctx.body.name,
|
|
78
|
-
emailVerified: true,
|
|
79
|
-
role: noGlobalRole
|
|
80
|
-
}, { method: "invitation" });
|
|
81
|
-
if (password !== void 0) await internalAdapter.linkAccount({
|
|
82
|
-
userId: user.id,
|
|
83
|
-
providerId: "credential",
|
|
84
|
-
accountId: user.id,
|
|
85
|
-
password: await ctx.context.password.hash(password)
|
|
86
|
-
});
|
|
87
|
-
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
88
|
-
const session = await internalAdapter.createSession(user.id, false);
|
|
89
|
-
await startIn(ctx.context, session.token, invitation.organizationId);
|
|
90
|
-
await setSessionCookie(ctx, {
|
|
91
|
-
session: {
|
|
92
|
-
...session,
|
|
93
|
-
activeOrganizationId: invitation.organizationId
|
|
94
|
-
},
|
|
95
|
-
user
|
|
96
|
-
});
|
|
97
|
-
return ctx.json({ member });
|
|
98
|
-
})
|
|
99
|
-
});
|
|
100
|
-
/**
|
|
101
|
-
* Without a mailer the invitation cannot be emailed, so whoever invites gets its link in the answer, once, to pass on.
|
|
102
|
-
* Resending gives a new link, since it moves the expiry.
|
|
103
|
-
*/
|
|
104
|
-
const invitationLinkHook = (resolved) => ({
|
|
105
|
-
matcher: (context) => context.path === "/organization/invite-member",
|
|
106
|
-
handler: createAuthMiddleware(async (ctx) => {
|
|
107
|
-
const returned = ctx.context.returned;
|
|
108
|
-
if (!returned || returned instanceof Response || returned instanceof Error || !returned.id || !returned.expiresAt) return;
|
|
109
|
-
const token = await invitationToken(ctx.context.secret, {
|
|
110
|
-
id: returned.id,
|
|
111
|
-
expiresAt: new Date(returned.expiresAt)
|
|
112
|
-
});
|
|
113
|
-
ctx.context.returned = {
|
|
114
|
-
...returned,
|
|
115
|
-
link: invitationLink(resolved.invitationUrl, token)
|
|
116
|
-
};
|
|
117
|
-
})
|
|
118
|
-
});
|
|
9
|
+
const invitationEndpoints = ({ resolved }) => ({
|
|
10
|
+
/** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
|
|
11
|
+
acceptInvitation: createAuthEndpoint("/invitation/accept", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
body: z$1.object({ token: z$1.string().min(1) }),
|
|
14
|
+
use: [sessionMiddleware]
|
|
15
|
+
}, async (ctx) => {
|
|
16
|
+
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
17
|
+
const { user, session } = ctx.context.session;
|
|
18
|
+
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
19
|
+
await ctx.context.internalAdapter.updateSession(session.token, { activeOrganizationId: invitation.organizationId });
|
|
20
|
+
return ctx.json({ member });
|
|
21
|
+
}) });
|
|
119
22
|
//#endregion
|
|
120
|
-
export { invitationEndpoints
|
|
23
|
+
export { invitationEndpoints };
|
|
@@ -8,7 +8,7 @@ const memberNotFound = {
|
|
|
8
8
|
code: "MEMBER_NOT_FOUND",
|
|
9
9
|
message: "There is no such member."
|
|
10
10
|
};
|
|
11
|
-
const
|
|
11
|
+
const pageSize = 20;
|
|
12
12
|
/**
|
|
13
13
|
* Protobase's member endpoints beside Better Auth's: changing a member's app roles, handing ownership over in one step,
|
|
14
14
|
* and, for people with a global role, finding any organization to work in.
|
|
@@ -144,38 +144,55 @@ const memberEndpoints = ({ resolved }) => ({
|
|
|
144
144
|
});
|
|
145
145
|
}),
|
|
146
146
|
/**
|
|
147
|
-
* `GET /organization/search?query=`, for someone with a global role, who may work in any organization:
|
|
148
|
-
*
|
|
147
|
+
* `GET /organization/search?query=&offset=`, for someone with a global role, who may work in any organization: every
|
|
148
|
+
* organization by slug, a page at a time, narrowed by the text in its name or slug, with the total.
|
|
149
149
|
*/
|
|
150
150
|
searchOrganizations: createAuthEndpoint("/organization/search", {
|
|
151
151
|
method: "GET",
|
|
152
152
|
use: [sessionMiddleware],
|
|
153
|
-
query: z$1.object({
|
|
153
|
+
query: z$1.object({
|
|
154
|
+
query: z$1.string().default(""),
|
|
155
|
+
offset: z$1.coerce.number().int().min(0).default(0)
|
|
156
|
+
})
|
|
154
157
|
}, async (ctx) => {
|
|
155
158
|
if (globalRoles(ctx.context.session.user.role).length === 0) throw new APIError("FORBIDDEN", {
|
|
156
159
|
code: "GLOBAL_ROLE_REQUIRED",
|
|
157
160
|
message: "Only people with a global role can open organizations they are not a member of."
|
|
158
161
|
});
|
|
159
162
|
const text = ctx.query.query.trim();
|
|
160
|
-
const
|
|
163
|
+
const where = text ? [{
|
|
164
|
+
field: "name",
|
|
165
|
+
operator: "contains",
|
|
166
|
+
value: text,
|
|
167
|
+
connector: "OR"
|
|
168
|
+
}, {
|
|
169
|
+
field: "slug",
|
|
170
|
+
operator: "contains",
|
|
171
|
+
value: text,
|
|
172
|
+
connector: "OR"
|
|
173
|
+
}] : void 0;
|
|
174
|
+
const organizations = await ctx.context.adapter.findMany({
|
|
161
175
|
model: "organization",
|
|
162
|
-
|
|
163
|
-
field,
|
|
164
|
-
value: text,
|
|
165
|
-
operator: "contains"
|
|
166
|
-
}] },
|
|
176
|
+
where,
|
|
167
177
|
sortBy: {
|
|
168
|
-
field: "
|
|
178
|
+
field: "slug",
|
|
169
179
|
direction: "asc"
|
|
170
180
|
},
|
|
171
|
-
limit:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
181
|
+
limit: pageSize,
|
|
182
|
+
offset: ctx.query.offset
|
|
183
|
+
});
|
|
184
|
+
const total = await ctx.context.adapter.count({
|
|
185
|
+
model: "organization",
|
|
186
|
+
where
|
|
187
|
+
});
|
|
188
|
+
return ctx.json({
|
|
189
|
+
organizations: organizations.map(({ id, name, slug }) => ({
|
|
190
|
+
id,
|
|
191
|
+
name,
|
|
192
|
+
slug
|
|
193
|
+
})),
|
|
194
|
+
total
|
|
195
|
+
});
|
|
179
196
|
})
|
|
180
197
|
});
|
|
181
198
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultInvitationUrl } from "../invitations/invitation-link-hook.js";
|
|
1
2
|
import { membershipRoles } from "./role-definitions.js";
|
|
2
3
|
//#region src/better-auth/organizations/options.ts
|
|
3
4
|
const resolveOrganizations = (options, definitions, baseURL) => {
|
|
@@ -15,7 +16,7 @@ const resolveOrganizations = (options, definitions, baseURL) => {
|
|
|
15
16
|
membershipLimit: options.membershipLimit ?? 1e3,
|
|
16
17
|
generateId: options.generateId ?? (() => crypto.randomUUID()),
|
|
17
18
|
deleteRecords: options.deleteRecords ?? "refuse",
|
|
18
|
-
invitationUrl: options.invitationUrl ??
|
|
19
|
+
invitationUrl: options.invitationUrl ?? defaultInvitationUrl(baseURL),
|
|
19
20
|
options
|
|
20
21
|
};
|
|
21
22
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
|
|
1
2
|
import "./role-definitions.js";
|
|
2
3
|
import { checkAppRoleChange, effectiveRoles, globalRoles, parseAppRoles } from "./app-roles.js";
|
|
3
4
|
import { findMember } from "./store.js";
|
|
4
5
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
5
|
-
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
6
6
|
import { APIError } from "better-auth/api";
|
|
7
7
|
import { organization } from "better-auth/plugins/organization";
|
|
8
8
|
//#region src/better-auth/organizations/organization-plugin.ts
|
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { invitationLinkHook } from "../invitations/invitation-link-hook.js";
|
|
2
|
+
import { invitationEndpoints } from "./invitation-endpoints.js";
|
|
2
3
|
import { memberEndpoints } from "./member-endpoints.js";
|
|
3
4
|
//#region src/better-auth/organizations/protobase-plugin.ts
|
|
4
5
|
/**
|
|
5
|
-
* Protobase's endpoints beside Better Auth's organization plugin:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Protobase's endpoints beside Better Auth's organization plugin: accepting invitations signed in, app roles, handing
|
|
7
|
+
* ownership over, and finding organizations for people with a global role. Without a mailer, whoever invites gets the
|
|
8
|
+
* link in the answer.
|
|
8
9
|
*/
|
|
9
10
|
const protobaseOrganizationPlugin = ({ resolved, mail }) => ({
|
|
10
11
|
id: "protobase-organizations",
|
|
11
12
|
endpoints: {
|
|
12
|
-
...invitationEndpoints({
|
|
13
|
-
resolved,
|
|
14
|
-
mail
|
|
15
|
-
}),
|
|
13
|
+
...invitationEndpoints({ resolved }),
|
|
16
14
|
...memberEndpoints({ resolved })
|
|
17
15
|
},
|
|
18
|
-
hooks: { after: mail ? [] : [invitationLinkHook(resolved)] }
|
|
16
|
+
hooks: { after: mail ? [] : [invitationLinkHook(["/organization/invite-member"], resolved.invitationUrl)] }
|
|
19
17
|
});
|
|
20
18
|
//#endregion
|
|
21
19
|
export { protobaseOrganizationPlugin };
|
|
@@ -5,10 +5,10 @@ const superuserRole = "admin";
|
|
|
5
5
|
/** The admin plugin's role for an account without a global role; in organization mode it grants nothing. */
|
|
6
6
|
const noGlobalRole = "user";
|
|
7
7
|
const organizationRoleNames = ["owner", "member"];
|
|
8
|
-
const builtInLabels = {
|
|
8
|
+
const builtInLabels = (organizations) => ({
|
|
9
9
|
[superuserRole]: "Superuser",
|
|
10
|
-
[noGlobalRole]: "No global role"
|
|
11
|
-
};
|
|
10
|
+
[noGlobalRole]: organizations ? "No global role" : "User"
|
|
11
|
+
});
|
|
12
12
|
const entriesOf = (input) => Array.isArray(input) ? input.map((name) => [name, void 0]) : Object.entries(input);
|
|
13
13
|
/**
|
|
14
14
|
* The roles with `admin` first (added when missing), their labels and grants. With `organizations`, every role needs a
|
|
@@ -26,10 +26,11 @@ const roleDefinitions = (input = ["admin", "user"], { organizations = false } =
|
|
|
26
26
|
...organizations && !given.has("user") ? [noGlobalRole] : []
|
|
27
27
|
];
|
|
28
28
|
const labels = {};
|
|
29
|
+
const builtIn = builtInLabels(organizations);
|
|
29
30
|
const grants = {};
|
|
30
31
|
for (const name of names) {
|
|
31
32
|
const definition = given.get(name);
|
|
32
|
-
const label = typeof definition === "string" ? definition : definition?.label ??
|
|
33
|
+
const label = typeof definition === "string" ? definition : definition?.label ?? builtIn[name];
|
|
33
34
|
if (label !== void 0) labels[name] = label;
|
|
34
35
|
grants[name] = typeof definition === "object" ? definition.grants ?? [] : [];
|
|
35
36
|
const unknown = grants[name].find((granted) => !names.includes(granted));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { pluginNames } from "./snake-case-names.js";
|
|
2
2
|
import * as z$1 from "zod";
|
|
3
3
|
import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
|
4
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
5
4
|
import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
|
|
6
5
|
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
6
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
7
7
|
import { generateRandomString } from "better-auth/crypto";
|
|
8
8
|
//#region src/better-auth/passkey-plugin.ts
|
|
9
9
|
const challengeCookie = "better-auth-passkey";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { membershipRoles } from "../organizations/role-definitions.js";
|
|
2
|
+
//#region src/better-auth/roles/role-labels.ts
|
|
3
|
+
/** Every role with its label, `membership` for those a member can hold, and the roles each one's holders may also give. */
|
|
4
|
+
const roleLabels = (definitions) => {
|
|
5
|
+
const membership = membershipRoles(definitions);
|
|
6
|
+
return definitions.names.map((name) => ({
|
|
7
|
+
name,
|
|
8
|
+
label: definitions.labels[name] ?? name,
|
|
9
|
+
membership: membership.includes(name),
|
|
10
|
+
grants: definitions.grants[name] ?? []
|
|
11
|
+
}));
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { roleLabels };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
2
|
-
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
3
2
|
import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
4
3
|
import { readSignInPolicy } from "../policy-store.js";
|
|
4
|
+
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
5
5
|
import { consumeSignUpCode, issueSignUpCode, signUpCodeLifetime } from "./sign-up-code.js";
|
|
6
6
|
import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
|
|
7
7
|
import * as z$1 from "zod";
|