feat(orgs): multi-user workspaces — roles, invitations, membership enforcement (#473)
This commit is contained in:
parent
4d7fb661f0
commit
ea162a4391
@ -37,6 +37,7 @@
|
|||||||
# LOOPS_API_KEY=replace-with-your-loops-api-key
|
# LOOPS_API_KEY=replace-with-your-loops-api-key
|
||||||
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
|
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
|
||||||
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
|
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
|
||||||
|
# LOOPS_TRANSACTIONAL_INVITATION_ID=replace-with-your-loops-invitation-template-id
|
||||||
|
|
||||||
# Optional in self-hosted modes. Required if you want AI features like SAM,
|
# Optional in self-hosted modes. Required if you want AI features like SAM,
|
||||||
# the in-app SEO agent. Create a key at https://openrouter.ai/settings/keys.
|
# the in-app SEO agent. Create a key at https://openrouter.ai/settings/keys.
|
||||||
|
|||||||
@ -26,6 +26,7 @@ DATABASE_PROVIDER=postgres
|
|||||||
# LOOPS_API_KEY=
|
# LOOPS_API_KEY=
|
||||||
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=
|
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=
|
||||||
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=
|
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=
|
||||||
|
# LOOPS_TRANSACTIONAL_INVITATION_ID=
|
||||||
# TURNSTILE_SECRET_KEY=
|
# TURNSTILE_SECRET_KEY=
|
||||||
# TURNSTILE_SITE_KEY=
|
# TURNSTILE_SITE_KEY=
|
||||||
|
|
||||||
|
|||||||
@ -283,6 +283,9 @@ const dataEnv = {
|
|||||||
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar(
|
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar(
|
||||||
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
|
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
|
||||||
),
|
),
|
||||||
|
LOOPS_TRANSACTIONAL_INVITATION_ID: optionalVar(
|
||||||
|
"LOOPS_TRANSACTIONAL_INVITATION_ID",
|
||||||
|
),
|
||||||
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
|
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
|
||||||
POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
|
POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
|
||||||
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
|
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
|
||||||
|
|||||||
1
drizzle-pg/0021_user_last_active_organization.sql
Normal file
1
drizzle-pg/0021_user_last_active_organization.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "last_active_organization_id" text;
|
||||||
1
drizzle-pg/0022_third_supernaut.sql
Normal file
1
drizzle-pg/0022_third_supernaut.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX "member_organizationId_userId_uidx" ON "member" USING btree ("organization_id","user_id");
|
||||||
4099
drizzle-pg/meta/0021_snapshot.json
Normal file
4099
drizzle-pg/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4120
drizzle-pg/meta/0022_snapshot.json
Normal file
4120
drizzle-pg/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -148,6 +148,20 @@
|
|||||||
"when": 1787099999115,
|
"when": 1787099999115,
|
||||||
"tag": "0020_project_memory",
|
"tag": "0020_project_memory",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787594239250,
|
||||||
|
"tag": "0021_user_last_active_organization",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787773500453,
|
||||||
|
"tag": "0022_third_supernaut",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1
drizzle/0043_user_last_active_organization.sql
Normal file
1
drizzle/0043_user_last_active_organization.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `last_active_organization_id` text;
|
||||||
1
drizzle/0044_outstanding_sage.sql
Normal file
1
drizzle/0044_outstanding_sage.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX `member_organizationId_userId_uidx` ON `member` (`organization_id`,`user_id`);
|
||||||
3739
drizzle/meta/0043_snapshot.json
Normal file
3739
drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3747
drizzle/meta/0044_snapshot.json
Normal file
3747
drizzle/meta/0044_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -302,6 +302,20 @@
|
|||||||
"when": 1787099999115,
|
"when": 1787099999115,
|
||||||
"tag": "0042_project_memory",
|
"tag": "0042_project_memory",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787594237405,
|
||||||
|
"tag": "0043_user_last_active_organization",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787773498579,
|
||||||
|
"tag": "0044_outstanding_sage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
95
specs/0011-multi-user-organizations.md
Normal file
95
specs/0011-multi-user-organizations.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# 0011 — Multi-user organizations
|
||||||
|
|
||||||
|
Decisions behind PR #473 (multi-user organizations). Hosted mode only; self-host
|
||||||
|
never mounts the organization endpoints and delegated identities resolve as
|
||||||
|
implicit owners.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Better Auth access control with three app statements on top of the built-ins
|
||||||
|
(`src/lib/org-permissions.ts`):
|
||||||
|
|
||||||
|
- `billing:manage` — **owner only** (subscribe, top-up, portal, cancel).
|
||||||
|
- `project:create/delete` and `integration:manage` (GSC/GA4 connect,
|
||||||
|
disconnect) — owner + admin.
|
||||||
|
- **Every invitee is an admin** (deliberate v1 call, confirmed twice): full
|
||||||
|
access to each project except billing. The `member` role is defined in code
|
||||||
|
but not invitable — enforced server-side in `beforeCreateInvitation`, which
|
||||||
|
also blocks owner-role invites (an owner minting a second owner and leaving
|
||||||
|
would re-mint a fresh org + free grant at next sign-in).
|
||||||
|
|
||||||
|
Roles are resolved from the member row per request and never baked into
|
||||||
|
sessions or tokens.
|
||||||
|
|
||||||
|
## Membership invariants
|
||||||
|
|
||||||
|
- Every user has at least one org once they use the app; org resolution
|
||||||
|
(`resolveActiveHostedOrganization`) creates a default organization on demand.
|
||||||
|
- **Invitations are the only path to multi-org membership**: users cannot
|
||||||
|
create additional orgs (`allowUserToCreateOrganization: false`) and cannot
|
||||||
|
delete orgs (`disableOrganizationDeletion: true` — deletion cascades all
|
||||||
|
data and would re-mint free-plan grants; it stays a support action).
|
||||||
|
- **Invite sign-ups get no personal organization.** The session-create hook
|
||||||
|
(`resolveSignInHostedOrganization`) defers default-organization creation for a
|
||||||
|
membership-less user with a pending invitation, so they land in exactly the
|
||||||
|
inviter's org. Abandoned/declined invites self-heal: the request path still
|
||||||
|
creates a organization on the next app visit. Existing users keep their
|
||||||
|
organization and simply gain the new org.
|
||||||
|
- Active org = `user.lastActiveOrganizationId` (validated against a live
|
||||||
|
membership) → newest membership → create default. The column is
|
||||||
|
load-bearing for the organization switcher and deliberately NOT a better-auth
|
||||||
|
`userAdditionalFields` field (it must not be user-writable via
|
||||||
|
`/update-user`).
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
- Every request re-validates the member row; removing a member revokes access
|
||||||
|
on their next request, including MCP OAuth tokens (per-request membership
|
||||||
|
401 in the transport, role stamped per request).
|
||||||
|
- **MCP API keys are user-scoped credentials** (`orgScope: "user"`), not
|
||||||
|
key→org bindings: project-scoped tools derive the org from the project row
|
||||||
|
and authorize via the caller's membership in that org, then bill that org.
|
||||||
|
One key works across all the user's organizations. `list_projects` spans
|
||||||
|
memberships; `create_project` requires an explicit `organizationId` when the
|
||||||
|
user belongs to more than one (the error lists the options and instructs
|
||||||
|
the agent to confirm with the user). OAuth tokens stay pinned to the org
|
||||||
|
stamped at consent until re-auth.
|
||||||
|
- Chat (SAM) re-checks membership every turn and fails closed in hosted mode
|
||||||
|
when the member row is gone — WebSockets authorize at connect time only,
|
||||||
|
so the per-turn check is what revokes a removed member's open socket.
|
||||||
|
|
||||||
|
## Invitations
|
||||||
|
|
||||||
|
- 7-day expiry; re-invite of a pending address re-mails the same link with a
|
||||||
|
refreshed expiry (the Team UI's Resend).
|
||||||
|
- The invite email is sent by the `sendTeamInvitation` server function, NOT
|
||||||
|
better-auth's `sendInvitationEmail` callback — better-auth swallows throws
|
||||||
|
from that callback, so a failed send would read as "sent". Send failures
|
||||||
|
fail the call visibly; the pending row stays for retry. Consequence: the
|
||||||
|
raw `/api/auth/organization/invite-member` endpoint creates pending rows
|
||||||
|
but emails nobody.
|
||||||
|
- Abuse bounds: 20 pending invitations per org (better-auth
|
||||||
|
`invitationLimit`) plus KV daily send counters — 5 sends/address, 50/org
|
||||||
|
(`src/server/auth/invitation-send-limit.ts`). In-memory rate limiting is a
|
||||||
|
per-isolate no-op on Workers; KV is the only counter that holds.
|
||||||
|
- Accepting works signed-out through sign-up + email verification and back;
|
||||||
|
accept sets the active org and skips first-run onboarding (which would
|
||||||
|
spend org credits and overwrite the shared project's domain).
|
||||||
|
- Free-plan audit quota is counted per organization, not per user.
|
||||||
|
|
||||||
|
## Accepted residuals
|
||||||
|
|
||||||
|
- Onboarding completion is stored per user, so an invitee's later personal
|
||||||
|
organization starts empty with no onboarding.
|
||||||
|
- `resolveHostedContext` runs twice on owner-only Autumn mutations.
|
||||||
|
- KV send counters race under concurrency — they are abuse bounds, not exact
|
||||||
|
quotas.
|
||||||
|
|
||||||
|
## Future (explicitly deferred)
|
||||||
|
|
||||||
|
- Per-project authorization inside an org (org membership without access to
|
||||||
|
every project). The single choke points to extend: `withMcpProjectAuth`
|
||||||
|
(MCP) and the canonical project-access path (web). Tracked as EVE-50's
|
||||||
|
remaining scope.
|
||||||
|
- Roles tighter than admin (expose `member`, decide its rank-tracking rights).
|
||||||
|
- Intentional multi-org creation, org deletion flow, seats.
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import type { LinkOptions } from "@tanstack/react-router";
|
import type { LinkOptions } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState, type ComponentType } from "react";
|
import { useEffect, useState, type ComponentType } from "react";
|
||||||
import {
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
|
Check,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@ -11,6 +14,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { organizationContextQueryOptions } from "@/client/features/team/organizationQueries";
|
||||||
|
import { switchOrganization } from "@/serverFunctions/organization";
|
||||||
import {
|
import {
|
||||||
connectNavGroup,
|
connectNavGroup,
|
||||||
getProjectNavGroups,
|
getProjectNavGroups,
|
||||||
@ -228,12 +233,33 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const email = session?.user?.email;
|
const email = session?.user?.email;
|
||||||
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
|
|
||||||
|
const orgContextQuery = useQuery({
|
||||||
|
...organizationContextQueryOptions(),
|
||||||
|
enabled: isHostedMode && Boolean(email),
|
||||||
|
});
|
||||||
|
const organizations = orgContextQuery.data?.organizations ?? [];
|
||||||
|
const activeOrganizationId = orgContextQuery.data?.organizationId;
|
||||||
|
|
||||||
const closeMenu = () => {
|
const closeMenu = () => {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleSwitchOrganization(organizationId: string) {
|
||||||
|
if (isSwitching || organizationId === activeOrganizationId) return;
|
||||||
|
setIsSwitching(true);
|
||||||
|
try {
|
||||||
|
await switchOrganization({ data: { organizationId } });
|
||||||
|
// Full reload: every cached query and the project-scoped URL belong to
|
||||||
|
// the previous organization.
|
||||||
|
window.location.assign("/");
|
||||||
|
} catch {
|
||||||
|
setIsSwitching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe">
|
<div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe">
|
||||||
<SidebarNavLink
|
<SidebarNavLink
|
||||||
@ -260,6 +286,38 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="dropdown-content z-30 menu mb-1 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
className="dropdown-content z-30 menu mb-1 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
|
{organizations.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<li className="menu-title flex flex-row items-center gap-1.5 max-w-full">
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
Organization
|
||||||
|
</li>
|
||||||
|
{organizations.map((organization) => (
|
||||||
|
<li key={organization.organizationId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSwitching}
|
||||||
|
onClick={() =>
|
||||||
|
void handleSwitchOrganization(
|
||||||
|
organization.organizationId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{organization.organizationName}
|
||||||
|
</span>
|
||||||
|
{organization.organizationId === activeOrganizationId ? (
|
||||||
|
<Check className="h-4 w-4 shrink-0" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none my-1 h-px bg-base-300 p-0"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<li>
|
<li>
|
||||||
<Link to="/settings" onClick={closeMenu}>
|
<Link to="/settings" onClick={closeMenu}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export function WorkspaceMergeBanner() {
|
|||||||
mutationFn: () => mergeLegacyWorkspaces(),
|
mutationFn: () => mergeLegacyWorkspaces(),
|
||||||
onSuccess: ({ mergedWorkspaces }) => {
|
onSuccess: ({ mergedWorkspaces }) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Migrated ${mergedWorkspaces} workspace${mergedWorkspaces === 1 ? "" : "s"} into the shared workspace.`,
|
`Migrated ${mergedWorkspaces} organization${mergedWorkspaces === 1 ? "" : "s"} into the shared organization.`,
|
||||||
);
|
);
|
||||||
// The merge changes projects, connections, and the banner's own status —
|
// The merge changes projects, connections, and the banner's own status —
|
||||||
// refetch everything rather than enumerating keys.
|
// refetch everything rather than enumerating keys.
|
||||||
@ -34,7 +34,7 @@ export function WorkspaceMergeBanner() {
|
|||||||
toast.error(
|
toast.error(
|
||||||
getStandardErrorMessage(
|
getStandardErrorMessage(
|
||||||
error,
|
error,
|
||||||
"Couldn't migrate the workspaces. Try again.",
|
"Couldn't migrate the organizations. Try again.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -57,7 +57,7 @@ export function WorkspaceMergeBanner() {
|
|||||||
disabled={mergeMutation.isPending}
|
disabled={mergeMutation.isPending}
|
||||||
onClick={() => mergeMutation.mutate()}
|
onClick={() => mergeMutation.mutate()}
|
||||||
>
|
>
|
||||||
{mergeMutation.isPending ? "Migrating…" : "Migrate workspaces"}
|
{mergeMutation.isPending ? "Migrating…" : "Migrate organizations"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -197,7 +197,7 @@ function DangerSection({
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/60">
|
||||||
{canArchive
|
{canArchive
|
||||||
? "Archive this project to remove it from your workspace."
|
? "Archive this project to remove it from your organization."
|
||||||
: "You can't archive your only project."}
|
: "You can't archive your only project."}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
96
src/client/features/team/InviteTeammateModal.tsx
Normal file
96
src/client/features/team/InviteTeammateModal.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getErrorCode } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { sendTeamInvitation } from "@/serverFunctions/organization";
|
||||||
|
|
||||||
|
export function inviteErrorMessage(error: Error) {
|
||||||
|
const code = getErrorCode(error);
|
||||||
|
if (code === "RATE_LIMITED") {
|
||||||
|
return "Invitation limit reached for today. Try again tomorrow.";
|
||||||
|
}
|
||||||
|
if (code === "UPSTREAM_UNAVAILABLE") {
|
||||||
|
return "The invitation was saved but the email couldn't be sent. Use Resend in a moment to retry.";
|
||||||
|
}
|
||||||
|
return "We couldn't send that invitation.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteTeammateModal({
|
||||||
|
onClose,
|
||||||
|
onInvited,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onInvited: () => void;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
// Server function (not authClient.inviteMember): it enforces the daily send
|
||||||
|
// limits and fails visibly when the invite email doesn't send.
|
||||||
|
const inviteMutation = useMutation({
|
||||||
|
mutationFn: (inviteeEmail: string) =>
|
||||||
|
sendTeamInvitation({ data: { email: inviteeEmail } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("team:invitation_send");
|
||||||
|
toast.success("Invitation sent");
|
||||||
|
onInvited();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(inviteErrorMessage(error));
|
||||||
|
// An email-send failure still creates the pending row — show it.
|
||||||
|
onInvited();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-md">
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = email.trim();
|
||||||
|
if (trimmed) inviteMutation.mutate(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold">Invite a teammate</h3>
|
||||||
|
<p className="mt-2 text-sm text-base-content/60">
|
||||||
|
They’ll join as an Admin with full access to each project
|
||||||
|
except for billing. The invitation link expires in 7 days.
|
||||||
|
</p>
|
||||||
|
<label className="form-control mt-4 w-full">
|
||||||
|
<span className="label-text pb-1 text-xs text-base-content/60">
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input input-sm input-bordered w-full"
|
||||||
|
placeholder="teammate@company.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={inviteMutation.isPending || !email.trim()}
|
||||||
|
>
|
||||||
|
{inviteMutation.isPending ? "Sending…" : "Send invite"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/client/features/team/TeamSettings.tsx
Normal file
186
src/client/features/team/TeamSettings.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
InviteTeammateModal,
|
||||||
|
inviteErrorMessage,
|
||||||
|
} from "@/client/features/team/InviteTeammateModal";
|
||||||
|
import { organizationContextQueryOptions } from "@/client/features/team/organizationQueries";
|
||||||
|
import { InvitationRow, MemberRow } from "@/client/features/team/TeamTableRows";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
||||||
|
import { getTeam, sendTeamInvitation } from "@/serverFunctions/organization";
|
||||||
|
|
||||||
|
// The Organization tab of account settings: who has access to the active org.
|
||||||
|
export function TeamSettings() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
||||||
|
|
||||||
|
const orgContextQuery = useQuery(organizationContextQueryOptions());
|
||||||
|
|
||||||
|
const teamQuery = useQuery({
|
||||||
|
queryKey: ["organization-team", orgContextQuery.data?.organizationId],
|
||||||
|
queryFn: () => getTeam(),
|
||||||
|
enabled: orgContextQuery.data?.organizationId !== undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTeam = () =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["organization-team", orgContextQuery.data?.organizationId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same server call as inviting: for an already-pending address it re-mails
|
||||||
|
// the same link with a refreshed expiry.
|
||||||
|
const resendMutation = useMutation({
|
||||||
|
mutationFn: (email: string) => sendTeamInvitation({ data: { email } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("team:invitation_resend");
|
||||||
|
toast.success("Invitation resent");
|
||||||
|
void refreshTeam();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(inviteErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: async (memberId: string) => {
|
||||||
|
const result = await authClient.organization.removeMember({
|
||||||
|
memberIdOrEmail: memberId,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message || "Failed to remove the member");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("team:member_remove");
|
||||||
|
toast.success("Member removed");
|
||||||
|
void refreshTeam();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "We couldn't remove that member.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelInvitationMutation = useMutation({
|
||||||
|
mutationFn: async (invitationId: string) => {
|
||||||
|
const result = await authClient.organization.cancelInvitation({
|
||||||
|
invitationId,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(
|
||||||
|
result.error.message || "Failed to cancel the invitation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("team:invitation_cancel");
|
||||||
|
toast.success("Invitation canceled");
|
||||||
|
void refreshTeam();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "We couldn't cancel that invitation.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const role = orgContextQuery.data?.role ?? "member";
|
||||||
|
const canManageTeam = hasOrgPermission(role, { invitation: ["create"] });
|
||||||
|
// billing:manage is the owner-only statement (organization:delete is
|
||||||
|
// disabled app-wide, so it would read as a dead capability).
|
||||||
|
const isOwner = hasOrgPermission(role, { billing: ["manage"] });
|
||||||
|
|
||||||
|
const members = teamQuery.data?.members ?? [];
|
||||||
|
const pendingInvitations = teamQuery.data?.pendingInvitations ?? [];
|
||||||
|
|
||||||
|
if (teamQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
We couldn’t load your team right now.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-soft btn-sm"
|
||||||
|
onClick={() => void teamQuery.refetch()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">Members</h2>
|
||||||
|
{canManageTeam ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setIsInviteOpen(true)}
|
||||||
|
>
|
||||||
|
Invite teammate
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Teammates join as Admins. Admins have full access to each project except
|
||||||
|
for billing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{teamQuery.isPending ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<span className="loading loading-spinner loading-md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-base-300">
|
||||||
|
<table className="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<MemberRow
|
||||||
|
key={member.id}
|
||||||
|
member={member}
|
||||||
|
isSelf={member.userId === session?.user?.id}
|
||||||
|
canManageTeam={canManageTeam}
|
||||||
|
isOwner={isOwner}
|
||||||
|
isRemoving={removeMemberMutation.isPending}
|
||||||
|
onRemove={() => removeMemberMutation.mutate(member.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pendingInvitations.map((invitation) => (
|
||||||
|
<InvitationRow
|
||||||
|
key={invitation.id}
|
||||||
|
invitation={invitation}
|
||||||
|
canManageTeam={canManageTeam}
|
||||||
|
isResending={resendMutation.isPending}
|
||||||
|
isCanceling={cancelInvitationMutation.isPending}
|
||||||
|
onResend={() => resendMutation.mutate(invitation.email)}
|
||||||
|
onCancel={() =>
|
||||||
|
cancelInvitationMutation.mutate(invitation.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInviteOpen ? (
|
||||||
|
<InviteTeammateModal
|
||||||
|
onClose={() => setIsInviteOpen(false)}
|
||||||
|
onInvited={() => void refreshTeam()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/client/features/team/TeamTableRows.tsx
Normal file
178
src/client/features/team/TeamTableRows.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { Send, Trash2 } from "lucide-react";
|
||||||
|
import { PortalMenu } from "@/client/components/PortalMenu";
|
||||||
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
owner: "Owner",
|
||||||
|
admin: "Admin",
|
||||||
|
member: "Member",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRole(role: string) {
|
||||||
|
return role
|
||||||
|
.split(",")
|
||||||
|
.map((name) => ROLE_LABELS[name.trim()] ?? name.trim())
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
user: { name?: string | null; email: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Invitation = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role?: string | null;
|
||||||
|
expiresAt: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MemberRow({
|
||||||
|
member,
|
||||||
|
isSelf,
|
||||||
|
canManageTeam,
|
||||||
|
isOwner,
|
||||||
|
isRemoving,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
member: Member;
|
||||||
|
isSelf: boolean;
|
||||||
|
canManageTeam: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
isRemoving: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const memberIsOwner = hasOrgPermission(member.role, {
|
||||||
|
billing: ["manage"],
|
||||||
|
});
|
||||||
|
// Owners are protected server-side (only an owner can touch an owner; the
|
||||||
|
// last owner can't be removed) — don't render controls that would just 403.
|
||||||
|
const canRemove = canManageTeam && !isSelf && (!memberIsOwner || isOwner);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover">
|
||||||
|
<td className="max-w-[280px]">
|
||||||
|
<p className="truncate font-medium" data-ph-mask>
|
||||||
|
{member.user.name || member.user.email}
|
||||||
|
{isSelf ? (
|
||||||
|
<span className="font-normal text-base-content/50"> (you)</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-base-content/50" data-ph-mask>
|
||||||
|
{member.user.email}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge badge-ghost badge-sm">
|
||||||
|
{formatRole(member.role)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-xs text-base-content/70">Active</td>
|
||||||
|
<td>
|
||||||
|
{canRemove ? (
|
||||||
|
<PortalMenu
|
||||||
|
ariaLabel={`Actions for ${member.user.email}`}
|
||||||
|
menuClassName="w-52"
|
||||||
|
>
|
||||||
|
{(close) => (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="text-error"
|
||||||
|
disabled={isRemoving}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Remove ${member.user.email} from this organization? They lose access immediately.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Remove member
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</PortalMenu>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvitationRow({
|
||||||
|
invitation,
|
||||||
|
canManageTeam,
|
||||||
|
isResending,
|
||||||
|
isCanceling,
|
||||||
|
onResend,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
invitation: Invitation;
|
||||||
|
canManageTeam: boolean;
|
||||||
|
isResending: boolean;
|
||||||
|
isCanceling: boolean;
|
||||||
|
onResend: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr className="hover">
|
||||||
|
<td className="max-w-[280px]">
|
||||||
|
<p className="truncate font-medium" data-ph-mask>
|
||||||
|
{invitation.email}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge badge-ghost badge-sm">
|
||||||
|
{formatRole(invitation.role ?? "member")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-xs text-base-content/70">
|
||||||
|
Invited · expires{" "}
|
||||||
|
{new Date(invitation.expiresAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{canManageTeam ? (
|
||||||
|
<PortalMenu
|
||||||
|
ariaLabel={`Actions for the invitation to ${invitation.email}`}
|
||||||
|
menuClassName="w-52"
|
||||||
|
>
|
||||||
|
{(close) => (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
disabled={isResending}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onResend();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send className="size-3.5" />
|
||||||
|
Resend invitation
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="text-error"
|
||||||
|
disabled={isCanceling}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Cancel invitation
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PortalMenu>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/client/features/team/organizationQueries.ts
Normal file
19
src/client/features/team/organizationQueries.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||||
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
||||||
|
import { getOrganizationContext } from "@/serverFunctions/organization";
|
||||||
|
|
||||||
|
export const organizationContextQueryOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["organization-context"],
|
||||||
|
queryFn: () => getOrganizationContext(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// True while loading: the sole-owner workspace is the overwhelmingly common
|
||||||
|
// case, and every billing surface is enforced server-side anyway — favor not
|
||||||
|
// flashing "ask your owner" at actual owners. This is a deliberate cosmetic
|
||||||
|
// choice, not an authorization gate; the server always re-checks billing:manage.
|
||||||
|
export function useCanManageBilling() {
|
||||||
|
const { data } = useQuery(organizationContextQueryOptions());
|
||||||
|
return data ? hasOrgPermission(data.role, { billing: ["manage"] }) : true;
|
||||||
|
}
|
||||||
@ -23,6 +23,10 @@ export const user = sqliteTable("user", {
|
|||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
|
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
|
||||||
|
// The org this user last worked in; seeds session.activeOrganizationId at
|
||||||
|
// sign-in (validated against a live membership first). Not a FK: an org
|
||||||
|
// delete must not fail because a user's last-active pointer references it.
|
||||||
|
lastActiveOrganizationId: text("last_active_organization_id"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = sqliteTable(
|
export const session = sqliteTable(
|
||||||
@ -137,6 +141,10 @@ export const member = sqliteTable(
|
|||||||
(table) => [
|
(table) => [
|
||||||
index("member_organizationId_idx").on(table.organizationId),
|
index("member_organizationId_idx").on(table.organizationId),
|
||||||
index("member_userId_idx").on(table.userId),
|
index("member_userId_idx").on(table.userId),
|
||||||
|
uniqueIndex("member_organizationId_userId_uidx").on(
|
||||||
|
table.organizationId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const user = pgTable("user", {
|
|||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
analyticsOptedOut: boolean("analytics_opted_out"),
|
analyticsOptedOut: boolean("analytics_opted_out"),
|
||||||
|
// The org this user last worked in; seeds session.activeOrganizationId at
|
||||||
|
// sign-in (validated against a live membership first). Not a FK: an org
|
||||||
|
// delete must not fail because a user's last-active pointer references it.
|
||||||
|
lastActiveOrganizationId: text("last_active_organization_id"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = pgTable(
|
||||||
@ -128,6 +132,10 @@ export const member = pgTable(
|
|||||||
(table) => [
|
(table) => [
|
||||||
index("member_organizationId_idx").on(table.organizationId),
|
index("member_organizationId_idx").on(table.organizationId),
|
||||||
index("member_userId_idx").on(table.userId),
|
index("member_userId_idx").on(table.userId),
|
||||||
|
uniqueIndex("member_organizationId_userId_uidx").on(
|
||||||
|
table.organizationId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -264,6 +264,8 @@ const REQUIRED_BETTER_AUTH_INDEXES: {
|
|||||||
{ table: "organization", columns: ["slug"], unique: true },
|
{ table: "organization", columns: ["slug"], unique: true },
|
||||||
{ table: "member", columns: ["organization_id"], unique: false },
|
{ table: "member", columns: ["organization_id"], unique: false },
|
||||||
{ table: "member", columns: ["user_id"], unique: false },
|
{ table: "member", columns: ["user_id"], unique: false },
|
||||||
|
// Backstop for duplicate memberships (also guarded by beforeAcceptInvitation).
|
||||||
|
{ table: "member", columns: ["organization_id", "user_id"], unique: true },
|
||||||
{ table: "invitation", columns: ["organization_id"], unique: false },
|
{ table: "invitation", columns: ["organization_id"], unique: false },
|
||||||
{ table: "invitation", columns: ["email"], unique: false },
|
{ table: "invitation", columns: ["email"], unique: false },
|
||||||
];
|
];
|
||||||
|
|||||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -41,6 +41,7 @@ declare namespace Cloudflare {
|
|||||||
LOOPS_API_KEY?: string;
|
LOOPS_API_KEY?: string;
|
||||||
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
|
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
|
||||||
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
|
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
|
||||||
|
LOOPS_TRANSACTIONAL_INVITATION_ID?: string;
|
||||||
AUTUMN_SECRET_KEY?: string;
|
AUTUMN_SECRET_KEY?: string;
|
||||||
AUTUMN_WEBHOOK_SECRET?: string;
|
AUTUMN_WEBHOOK_SECRET?: string;
|
||||||
// Dub referral conversion tracking (hosted only); all Dub code no-ops
|
// Dub referral conversion tracking (hosted only); all Dub code no-ops
|
||||||
|
|||||||
@ -7,13 +7,17 @@ import {
|
|||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||||
import { userAdditionalFields } from "@/lib/auth-options";
|
import { userAdditionalFields } from "@/lib/auth-options";
|
||||||
|
import { orgAccessControl, orgRoles } from "@/lib/org-permissions";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
plugins: [
|
plugins: [
|
||||||
apiKeyClient(),
|
apiKeyClient(),
|
||||||
organizationClient(),
|
// ac/roles must match the server plugin exactly, otherwise the client's
|
||||||
|
// synchronous checkRolePermission evaluates against the defaults and
|
||||||
|
// disagrees with the server.
|
||||||
|
organizationClient({ ac: orgAccessControl, roles: orgRoles }),
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
inferAdditionalFields({ user: userAdditionalFields }),
|
inferAdditionalFields({ user: userAdditionalFields }),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { genericOAuth, organization } from "better-auth/plugins";
|
import { genericOAuth, organization } from "better-auth/plugins";
|
||||||
import { baseAuthOptions } from "@/lib/auth-options";
|
import { baseAuthOptions } from "@/lib/auth-options";
|
||||||
|
import { orgAccessControl, orgRoles } from "@/lib/org-permissions";
|
||||||
import { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
|
import { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
|
||||||
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
||||||
|
|
||||||
export function createBaseAuthConfig() {
|
type OrganizationOptions = NonNullable<Parameters<typeof organization>[0]>;
|
||||||
|
|
||||||
|
const INVITATION_EXPIRES_IN_SECONDS = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
export function createBaseAuthConfig(options?: {
|
||||||
|
organization?: Pick<OrganizationOptions, "organizationHooks">;
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
...baseAuthOptions,
|
...baseAuthOptions,
|
||||||
advanced: {
|
advanced: {
|
||||||
@ -44,23 +51,21 @@ export function createBaseAuthConfig() {
|
|||||||
// server-side at signup via `auth.api.createOrganization({ body: { userId }})`
|
// server-side at signup via `auth.api.createOrganization({ body: { userId }})`
|
||||||
// — that's a "system action" (no session + userId in body) which better-auth
|
// — that's a "system action" (no session + userId in body) which better-auth
|
||||||
// exempts from this flag, so the bootstrap keeps working.
|
// exempts from this flag, so the bootstrap keeps working.
|
||||||
//
|
|
||||||
// invitationLimit: 0 closes the other path to multi-org membership.
|
|
||||||
// "One user, one workspace" is a billing invariant: MCP API keys bill the
|
|
||||||
// user's first org, sessions bill the active org — identical only while
|
|
||||||
// users can't be invited into a second workspace. Remove this when teams
|
|
||||||
// ship, in the same change that moves API-key requests to project-level
|
|
||||||
// authz (org derived per tool call from the project; keys stay
|
|
||||||
// user-scoped, no key→workspace binding).
|
|
||||||
//
|
|
||||||
// disableOrganizationDeletion closes the delete side of the same loop:
|
|
||||||
// POST /api/auth/organization/delete (owner-callable by default) would
|
|
||||||
// cascade-delete the workspace, and the next request auto-creates a fresh
|
|
||||||
// org id — a fresh Autumn customer with a fresh credit grant.
|
|
||||||
organization({
|
organization({
|
||||||
allowUserToCreateOrganization: false,
|
allowUserToCreateOrganization: false,
|
||||||
invitationLimit: 0,
|
ac: orgAccessControl,
|
||||||
|
roles: orgRoles,
|
||||||
|
// No self-serve delete: it would cascade projects/members/activation
|
||||||
|
// state, strand the org's Autumn customer, and (with the signup
|
||||||
|
// bootstrap re-minting a fresh org + free grant on next login) act as
|
||||||
|
// a credit-farming loop. Deletion stays a support action.
|
||||||
disableOrganizationDeletion: true,
|
disableOrganizationDeletion: true,
|
||||||
|
invitationExpiresIn: INVITATION_EXPIRES_IN_SECONDS,
|
||||||
|
// DB-backed bound on outstanding pending invitations per org — the
|
||||||
|
// only rate control that actually holds on Workers (in-memory rate
|
||||||
|
// limiting is per-isolate).
|
||||||
|
invitationLimit: 20,
|
||||||
|
...options?.organization,
|
||||||
}),
|
}),
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
|
|||||||
@ -18,7 +18,9 @@ import {
|
|||||||
getHostedTurnstileSecretKey,
|
getHostedTurnstileSecretKey,
|
||||||
hasHostedTurnstileConfig,
|
hasHostedTurnstileConfig,
|
||||||
} from "@/lib/auth-turnstile";
|
} from "@/lib/auth-turnstile";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { resolveSignInHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
|
import { onInvitationAccepted } from "@/server/auth/invited-member";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { captureDubReferralSignup } from "@/server/referrals/dub";
|
import { captureDubReferralSignup } from "@/server/referrals/dub";
|
||||||
import {
|
import {
|
||||||
sendHostedPasswordResetEmail,
|
sendHostedPasswordResetEmail,
|
||||||
@ -45,7 +47,72 @@ function createAuth() {
|
|||||||
? getHostedBaseUrl()
|
? getHostedBaseUrl()
|
||||||
: "http://localhost";
|
: "http://localhost";
|
||||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
const baseAuthConfig = createBaseAuthConfig();
|
const baseAuthConfig = createBaseAuthConfig(
|
||||||
|
isHostedAuthMode(env.AUTH_MODE)
|
||||||
|
? {
|
||||||
|
organization: {
|
||||||
|
// No sendInvitationEmail here on purpose: better-auth swallows a
|
||||||
|
// throw from that callback, so a failed send would still read as
|
||||||
|
// "sent" in the UI. The invite email is sent (and rate limited)
|
||||||
|
// by the sendTeamInvitation server function instead, which fails
|
||||||
|
// the call visibly. Side effect worth knowing: hitting the raw
|
||||||
|
// /api/auth/organization/invite-member endpoint creates a pending
|
||||||
|
// invitation but emails nobody.
|
||||||
|
organizationHooks: {
|
||||||
|
// The invite UI only offers "admin", but the endpoint accepts
|
||||||
|
// any role string; enforce server-side. This also keeps an
|
||||||
|
// owner from minting a second owner and leaving — the path
|
||||||
|
// that would re-mint a fresh org + free-plan grant at next
|
||||||
|
// sign-in.
|
||||||
|
beforeCreateInvitation: async ({ invitation }) => {
|
||||||
|
if (invitation.role !== "admin") {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Teammates can only be invited as admins.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeAcceptInvitation: async ({ invitation, user }) => {
|
||||||
|
const existing = await AuthRepository.getMembership(
|
||||||
|
user.id,
|
||||||
|
invitation.organizationId,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "You are already a member of this organization.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// The invite flow only ever mints "admin", but the raw
|
||||||
|
// update-member-role endpoint accepts any role string and the
|
||||||
|
// plugin lets an owner grant owner to another member. Owners
|
||||||
|
// control billing, so a second owner is a billing-escalation
|
||||||
|
// path (and, if the first owner then leaves, a fresh-org /
|
||||||
|
// free-grant loop at next sign-in). Ownership transfers stay a
|
||||||
|
// support action — reject owner here.
|
||||||
|
beforeUpdateMemberRole: async ({ newRole }) => {
|
||||||
|
if (
|
||||||
|
newRole
|
||||||
|
.split(",")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.includes("owner")
|
||||||
|
) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message:
|
||||||
|
"The owner role can't be granted from here. Contact support to transfer ownership.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterAcceptInvitation: async ({ member: acceptedMember }) => {
|
||||||
|
await onInvitationAccepted({
|
||||||
|
userId: acceptedMember.userId,
|
||||||
|
organizationId: acceptedMember.organizationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Turnstile captcha on signup — hosted only. Enforcement is driven by the
|
// Turnstile captcha on signup — hosted only. Enforcement is driven by the
|
||||||
// server-side secret alone so a client build/runtime site-key mismatch cannot
|
// server-side secret alone so a client build/runtime site-key mismatch cannot
|
||||||
@ -169,9 +236,15 @@ function createAuth() {
|
|||||||
session: {
|
session: {
|
||||||
create: {
|
create: {
|
||||||
before: async (session) => {
|
before: async (session) => {
|
||||||
// Inject Better Auth's createOrganization here so the helper can
|
// Runs on every sign-in (each sign-in mints a session row).
|
||||||
// stay reusable without importing auth.ts and creating a cycle.
|
// Resolution order: last-active org while still a member → most
|
||||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
// recently joined org → newly created default organization —
|
||||||
|
// except that a membership-less user with a pending invitation
|
||||||
|
// gets no organization minted (null active org) so accepting the invite
|
||||||
|
// leaves them in exactly the inviter's org. Inject Better Auth's
|
||||||
|
// createOrganization so the helper can stay reusable without
|
||||||
|
// importing auth.ts and creating a cycle.
|
||||||
|
const resolved = await resolveSignInHostedOrganization(
|
||||||
session.userId,
|
session.userId,
|
||||||
(body) => auth.api.createOrganization({ body }),
|
(body) => auth.api.createOrganization({ body }),
|
||||||
);
|
);
|
||||||
@ -179,7 +252,7 @@ function createAuth() {
|
|||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
...session,
|
...session,
|
||||||
activeOrganizationId: organizationId,
|
activeOrganizationId: resolved?.organizationId ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
65
src/lib/org-permissions.ts
Normal file
65
src/lib/org-permissions.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { createAccessControl } from "better-auth/plugins/access";
|
||||||
|
import {
|
||||||
|
adminAc,
|
||||||
|
defaultStatements,
|
||||||
|
memberAc,
|
||||||
|
ownerAc,
|
||||||
|
} from "better-auth/plugins/organization/access";
|
||||||
|
|
||||||
|
// App-level resources layered on top of better-auth's built-in org statements
|
||||||
|
// (organization/member/invitation management, which the plugin's own endpoints
|
||||||
|
// already enforce). Spreading defaultStatements is required — a custom
|
||||||
|
// statement object otherwise REPLACES the built-in catalog and every plugin
|
||||||
|
// permission check fails.
|
||||||
|
const statement = {
|
||||||
|
...defaultStatements,
|
||||||
|
// Subscribe, top-ups, Stripe portal, cancel. Owner-only.
|
||||||
|
billing: ["manage"],
|
||||||
|
// Create + archive/restore projects. Renames/settings stay open to all.
|
||||||
|
project: ["create", "delete"],
|
||||||
|
// GSC/GA4 connect, re-point, disconnect.
|
||||||
|
integration: ["manage"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const orgAccessControl = createAccessControl(statement);
|
||||||
|
|
||||||
|
export const orgRoles = {
|
||||||
|
owner: orgAccessControl.newRole({
|
||||||
|
...ownerAc.statements,
|
||||||
|
billing: ["manage"],
|
||||||
|
project: ["create", "delete"],
|
||||||
|
integration: ["manage"],
|
||||||
|
}),
|
||||||
|
admin: orgAccessControl.newRole({
|
||||||
|
...adminAc.statements,
|
||||||
|
project: ["create", "delete"],
|
||||||
|
integration: ["manage"],
|
||||||
|
}),
|
||||||
|
// Defined from day one so exposing it later is UI-only; not offered in the
|
||||||
|
// invite picker yet. Members can view everything and run research, but not
|
||||||
|
// manage the org, billing, projects, or integrations.
|
||||||
|
member: orgAccessControl.newRole({
|
||||||
|
...memberAc.statements,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPermissionRequest = Partial<{
|
||||||
|
[K in keyof typeof statement]: Array<(typeof statement)[K][number]>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const roleByName = new Map<string, (typeof orgRoles)[keyof typeof orgRoles]>(
|
||||||
|
Object.entries(orgRoles),
|
||||||
|
);
|
||||||
|
|
||||||
|
// better-auth stores multiple roles as one comma-separated string and ORs
|
||||||
|
// permission checks across them; mirror that here. Unknown role names fail
|
||||||
|
// closed.
|
||||||
|
export function hasOrgPermission(
|
||||||
|
role: string,
|
||||||
|
permissions: OrgPermissionRequest,
|
||||||
|
): boolean {
|
||||||
|
return role.split(",").some((name) => {
|
||||||
|
const candidate = roleByName.get(name.trim());
|
||||||
|
return candidate ? candidate.authorize(permissions).success : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -71,6 +71,9 @@ async function resolveDelegatedContext(
|
|||||||
// Delegated auth (Cloudflare Access / local) has no unverified state.
|
// Delegated auth (Cloudflare Access / local) has no unverified state.
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
// Delegated orgs are one-implicit-user with no member rows; that user has
|
||||||
|
// full control of their own workspace.
|
||||||
|
role: "owner",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +92,9 @@ export async function resolveSharedWorkspaceContext(
|
|||||||
userEmail: ensuredEmail,
|
userEmail: ensuredEmail,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
// The Access policy is the authorization boundary; everyone it admits has
|
||||||
|
// full control of the shared workspace.
|
||||||
|
role: "owner",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import type { EnsuredUserContext } from "./types";
|
import type { EnsuredUserContext } from "./types";
|
||||||
|
|
||||||
@ -28,29 +29,43 @@ export async function resolveHostedContext(
|
|||||||
const activeOrganizationId = getActiveOrganizationId(session);
|
const activeOrganizationId = getActiveOrganizationId(session);
|
||||||
|
|
||||||
if (activeOrganizationId) {
|
if (activeOrganizationId) {
|
||||||
|
// The session's activeOrganizationId is only a hint (it can outlive a
|
||||||
|
// membership: removal, org deletion, cookie cache). The member row is the
|
||||||
|
// authorization fact and also carries the caller's role.
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
session.user.id,
|
||||||
|
activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
return {
|
return {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
userEmail: session.user.email,
|
userEmail: session.user.email,
|
||||||
emailVerified: session.user.emailVerified ?? false,
|
emailVerified: session.user.emailVerified ?? false,
|
||||||
organizationId: activeOrganizationId,
|
organizationId: activeOrganizationId,
|
||||||
|
role: membership.role,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active org, or a stale one: re-resolve from live memberships (creating
|
||||||
|
// a default workspace only when the user has none) and repoint the session.
|
||||||
const authApi = getAuth().api;
|
const authApi = getAuth().api;
|
||||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
const resolved = await resolveActiveHostedOrganization(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
(body) => authApi.createOrganization({ body }),
|
(body) => authApi.createOrganization({ body }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await authApi.setActiveOrganization({
|
await authApi.setActiveOrganization({
|
||||||
headers,
|
headers,
|
||||||
body: { organizationId },
|
body: { organizationId: resolved.organizationId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
userEmail: session.user.email,
|
userEmail: session.user.email,
|
||||||
emailVerified: session.user.emailVerified ?? false,
|
emailVerified: session.user.emailVerified ?? false,
|
||||||
organizationId,
|
organizationId: resolved.organizationId,
|
||||||
|
role: resolved.role,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,5 +12,10 @@ export type EnsuredUserContext = {
|
|||||||
// gate paid onboarding spend behind verification.
|
// gate paid onboarding spend behind verification.
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
// The caller's role in organizationId, from their member row (comma-joined
|
||||||
|
// when multiple; check via hasOrgPermission, never string equality).
|
||||||
|
// Delegated modes (Cloudflare Access / local) have one implicit user per
|
||||||
|
// org and no member rows, so they resolve as "owner".
|
||||||
|
role: string;
|
||||||
project?: EnsuredProject;
|
project?: EnsuredProject;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,10 +19,12 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
|||||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||||
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
||||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
||||||
|
import { Route as AcceptInvitationIdRouteImport } from './routes/accept-invitation.$id'
|
||||||
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
|
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
|
||||||
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
|
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
|
||||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||||
|
import { Route as AppTeamRouteImport } from './routes/_app/team'
|
||||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||||
import { Route as AppProjectsRouteImport } from './routes/_app/projects'
|
import { Route as AppProjectsRouteImport } from './routes/_app/projects'
|
||||||
@ -30,9 +32,11 @@ import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
|||||||
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||||
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
|
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
|
||||||
|
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
|
||||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
|
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
|
||||||
|
import { Route as AppSettingsOrganizationRouteImport } from './routes/_app/settings/organization'
|
||||||
import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
|
import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
|
||||||
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||||
@ -104,6 +108,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
|
|||||||
path: '/api/health',
|
path: '/api/health',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AcceptInvitationIdRoute = AcceptInvitationIdRouteImport.update({
|
||||||
|
id: '/accept-invitation/$id',
|
||||||
|
path: '/accept-invitation/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
||||||
id: '/subscribe',
|
id: '/subscribe',
|
||||||
path: '/subscribe',
|
path: '/subscribe',
|
||||||
@ -125,6 +134,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({
|
|||||||
path: '/sign-in',
|
path: '/sign-in',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppTeamRoute = AppTeamRouteImport.update({
|
||||||
|
id: '/team',
|
||||||
|
path: '/team',
|
||||||
|
getParentRoute: () => AppRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppSupportRoute = AppSupportRouteImport.update({
|
const AppSupportRoute = AppSupportRouteImport.update({
|
||||||
id: '/support',
|
id: '/support',
|
||||||
path: '/support',
|
path: '/support',
|
||||||
@ -162,6 +176,11 @@ const AuthenticatedOnboardingIndexRoute =
|
|||||||
path: '/onboarding/',
|
path: '/onboarding/',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppSettingsIndexRoute = AppSettingsIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AppSettingsRoute,
|
||||||
|
} as any)
|
||||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||||
id: '/api/autumn/$',
|
id: '/api/autumn/$',
|
||||||
path: '/api/autumn/$',
|
path: '/api/autumn/$',
|
||||||
@ -178,6 +197,11 @@ const AuthenticatedOnboardingChatRoute =
|
|||||||
path: '/onboarding/chat',
|
path: '/onboarding/chat',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
|
||||||
|
id: '/organization',
|
||||||
|
path: '/organization',
|
||||||
|
getParentRoute: () => AppSettingsRoute,
|
||||||
|
} as any)
|
||||||
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
|
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
|
||||||
id: '/help/openrouter-api-key',
|
id: '/help/openrouter-api-key',
|
||||||
path: '/help/openrouter-api-key',
|
path: '/help/openrouter-api-key',
|
||||||
@ -323,19 +347,23 @@ export interface FileRoutesByFullPath {
|
|||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/projects': typeof AppProjectsRoute
|
'/projects': typeof AppProjectsRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRouteWithChildren
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
|
'/team': typeof AppTeamRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
||||||
|
'/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/settings/': typeof AppSettingsIndexRoute
|
||||||
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
@ -369,18 +397,21 @@ export interface FileRoutesByTo {
|
|||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/projects': typeof AppProjectsRoute
|
'/projects': typeof AppProjectsRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
|
'/team': typeof AppTeamRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
||||||
|
'/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/settings': typeof AppSettingsIndexRoute
|
||||||
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
|
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -415,20 +446,24 @@ export interface FileRoutesById {
|
|||||||
'/_app/ai': typeof AppAiRoute
|
'/_app/ai': typeof AppAiRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
'/_app/projects': typeof AppProjectsRoute
|
'/_app/projects': typeof AppProjectsRoute
|
||||||
'/_app/settings': typeof AppSettingsRoute
|
'/_app/settings': typeof AppSettingsRouteWithChildren
|
||||||
'/_app/support': typeof AppSupportRoute
|
'/_app/support': typeof AppSupportRoute
|
||||||
|
'/_app/team': typeof AppTeamRoute
|
||||||
'/_auth/sign-in': typeof AuthSignInRoute
|
'/_auth/sign-in': typeof AuthSignInRoute
|
||||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||||
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/_app/': typeof AppIndexRoute
|
'/_app/': typeof AppIndexRoute
|
||||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
|
||||||
|
'/_app/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/_app/settings/': typeof AppSettingsIndexRoute
|
||||||
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
@ -466,17 +501,21 @@ export interface FileRouteTypes {
|
|||||||
| '/projects'
|
| '/projects'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
|
| '/team'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/help/openrouter-api-key'
|
| '/help/openrouter-api-key'
|
||||||
|
| '/settings/organization'
|
||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/settings/'
|
||||||
| '/onboarding/'
|
| '/onboarding/'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
@ -510,18 +549,21 @@ export interface FileRouteTypes {
|
|||||||
| '/ai'
|
| '/ai'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/projects'
|
| '/projects'
|
||||||
| '/settings'
|
|
||||||
| '/support'
|
| '/support'
|
||||||
|
| '/team'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/help/openrouter-api-key'
|
| '/help/openrouter-api-key'
|
||||||
|
| '/settings/organization'
|
||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/settings'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
@ -557,18 +599,22 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/projects'
|
| '/_app/projects'
|
||||||
| '/_app/settings'
|
| '/_app/settings'
|
||||||
| '/_app/support'
|
| '/_app/support'
|
||||||
|
| '/_app/team'
|
||||||
| '/_auth/sign-in'
|
| '/_auth/sign-in'
|
||||||
| '/_auth/sign-up'
|
| '/_auth/sign-up'
|
||||||
| '/_authenticated/oauth-consent'
|
| '/_authenticated/oauth-consent'
|
||||||
| '/_authenticated/subscribe'
|
| '/_authenticated/subscribe'
|
||||||
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/_app/'
|
| '/_app/'
|
||||||
| '/_project/p/$projectId'
|
| '/_project/p/$projectId'
|
||||||
| '/_app/help/dataforseo-api-key'
|
| '/_app/help/dataforseo-api-key'
|
||||||
| '/_app/help/openrouter-api-key'
|
| '/_app/help/openrouter-api-key'
|
||||||
|
| '/_app/settings/organization'
|
||||||
| '/_authenticated/onboarding/chat'
|
| '/_authenticated/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/_app/settings/'
|
||||||
| '/_authenticated/onboarding/'
|
| '/_authenticated/onboarding/'
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
@ -603,6 +649,7 @@ export interface RootRouteChildren {
|
|||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
|
AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
@ -682,6 +729,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiHealthRouteImport
|
preLoaderRoute: typeof ApiHealthRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/accept-invitation/$id': {
|
||||||
|
id: '/accept-invitation/$id'
|
||||||
|
path: '/accept-invitation/$id'
|
||||||
|
fullPath: '/accept-invitation/$id'
|
||||||
|
preLoaderRoute: typeof AcceptInvitationIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authenticated/subscribe': {
|
'/_authenticated/subscribe': {
|
||||||
id: '/_authenticated/subscribe'
|
id: '/_authenticated/subscribe'
|
||||||
path: '/subscribe'
|
path: '/subscribe'
|
||||||
@ -710,6 +764,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSignInRouteImport
|
preLoaderRoute: typeof AuthSignInRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_app/team': {
|
||||||
|
id: '/_app/team'
|
||||||
|
path: '/team'
|
||||||
|
fullPath: '/team'
|
||||||
|
preLoaderRoute: typeof AppTeamRouteImport
|
||||||
|
parentRoute: typeof AppRouteRoute
|
||||||
|
}
|
||||||
'/_app/support': {
|
'/_app/support': {
|
||||||
id: '/_app/support'
|
id: '/_app/support'
|
||||||
path: '/support'
|
path: '/support'
|
||||||
@ -759,6 +820,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_app/settings/': {
|
||||||
|
id: '/_app/settings/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/settings/'
|
||||||
|
preLoaderRoute: typeof AppSettingsIndexRouteImport
|
||||||
|
parentRoute: typeof AppSettingsRoute
|
||||||
|
}
|
||||||
'/api/autumn/$': {
|
'/api/autumn/$': {
|
||||||
id: '/api/autumn/$'
|
id: '/api/autumn/$'
|
||||||
path: '/api/autumn/$'
|
path: '/api/autumn/$'
|
||||||
@ -780,6 +848,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
|
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_app/settings/organization': {
|
||||||
|
id: '/_app/settings/organization'
|
||||||
|
path: '/organization'
|
||||||
|
fullPath: '/settings/organization'
|
||||||
|
preLoaderRoute: typeof AppSettingsOrganizationRouteImport
|
||||||
|
parentRoute: typeof AppSettingsRoute
|
||||||
|
}
|
||||||
'/_app/help/openrouter-api-key': {
|
'/_app/help/openrouter-api-key': {
|
||||||
id: '/_app/help/openrouter-api-key'
|
id: '/_app/help/openrouter-api-key'
|
||||||
path: '/help/openrouter-api-key'
|
path: '/help/openrouter-api-key'
|
||||||
@ -951,12 +1026,27 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppSettingsRouteChildren {
|
||||||
|
AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute
|
||||||
|
AppSettingsIndexRoute: typeof AppSettingsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppSettingsRouteChildren: AppSettingsRouteChildren = {
|
||||||
|
AppSettingsOrganizationRoute: AppSettingsOrganizationRoute,
|
||||||
|
AppSettingsIndexRoute: AppSettingsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppSettingsRouteWithChildren = AppSettingsRoute._addFileChildren(
|
||||||
|
AppSettingsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface AppRouteRouteChildren {
|
interface AppRouteRouteChildren {
|
||||||
AppAiRoute: typeof AppAiRoute
|
AppAiRoute: typeof AppAiRoute
|
||||||
AppBillingRoute: typeof AppBillingRoute
|
AppBillingRoute: typeof AppBillingRoute
|
||||||
AppProjectsRoute: typeof AppProjectsRoute
|
AppProjectsRoute: typeof AppProjectsRoute
|
||||||
AppSettingsRoute: typeof AppSettingsRoute
|
AppSettingsRoute: typeof AppSettingsRouteWithChildren
|
||||||
AppSupportRoute: typeof AppSupportRoute
|
AppSupportRoute: typeof AppSupportRoute
|
||||||
|
AppTeamRoute: typeof AppTeamRoute
|
||||||
AppIndexRoute: typeof AppIndexRoute
|
AppIndexRoute: typeof AppIndexRoute
|
||||||
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
|
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
|
||||||
AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
|
AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
|
||||||
@ -966,8 +1056,9 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
|
|||||||
AppAiRoute: AppAiRoute,
|
AppAiRoute: AppAiRoute,
|
||||||
AppBillingRoute: AppBillingRoute,
|
AppBillingRoute: AppBillingRoute,
|
||||||
AppProjectsRoute: AppProjectsRoute,
|
AppProjectsRoute: AppProjectsRoute,
|
||||||
AppSettingsRoute: AppSettingsRoute,
|
AppSettingsRoute: AppSettingsRouteWithChildren,
|
||||||
AppSupportRoute: AppSupportRoute,
|
AppSupportRoute: AppSupportRoute,
|
||||||
|
AppTeamRoute: AppTeamRoute,
|
||||||
AppIndexRoute: AppIndexRoute,
|
AppIndexRoute: AppIndexRoute,
|
||||||
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
|
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
|
||||||
AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
|
AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
|
||||||
@ -1123,6 +1214,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
|
||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
||||||
|
AcceptInvitationIdRoute: AcceptInvitationIdRoute,
|
||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCustomer } from "autumn-js/react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
|
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
|
||||||
@ -43,6 +44,10 @@ function BillingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscription changes are owner-only; other members see balances but are
|
||||||
|
// pointed at the owner instead of checkout (the server enforces this too).
|
||||||
|
const canManageBilling = useCanManageBilling();
|
||||||
|
|
||||||
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
||||||
const isFreePlan = planStatus === "free";
|
const isFreePlan = planStatus === "free";
|
||||||
const billingRouteState = getBillingRouteState({
|
const billingRouteState = getBillingRouteState({
|
||||||
@ -174,7 +179,12 @@ function BillingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFreePlan ? (
|
{!canManageBilling ? (
|
||||||
|
<p className="border-t border-base-300 pt-3 text-sm text-base-content/60">
|
||||||
|
Only the organization owner can change the plan or buy credits.
|
||||||
|
Ask them if you need more.
|
||||||
|
</p>
|
||||||
|
) : isFreePlan ? (
|
||||||
<div className="space-y-3 border-t border-base-300 pt-3">
|
<div className="space-y-3 border-t border-base-300 pt-3">
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
<span className="text-sm font-medium">Base Plan</span>
|
<span className="text-sm font-medium">Base Plan</span>
|
||||||
@ -230,8 +240,8 @@ function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buy credits card — paid plan only */}
|
{/* Buy credits card — paid plan only, owner-only */}
|
||||||
{!isFreePlan ? (
|
{!isFreePlan && canManageBilling ? (
|
||||||
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Buy credits</span>
|
<span className="font-semibold">Buy credits</span>
|
||||||
|
|||||||
@ -76,7 +76,7 @@ function IndexRedirect() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<UnauthenticatedErrorCard
|
<UnauthenticatedErrorCard
|
||||||
message="Please sign in to access your OpenSEO workspace."
|
message="Please sign in to access your OpenSEO organization."
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -38,8 +38,8 @@ function ProjectsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
||||||
<p className="mt-1 text-sm text-base-content/60">
|
<p className="mt-1 text-sm text-base-content/60">
|
||||||
Each project is a separate workspace with its own Search Console,
|
Each project has its own Search Console, rank tracking, and
|
||||||
rank tracking, and audits.
|
audits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,134 +1,48 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
|
||||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { version } from "../../../package.json";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings")({
|
export const Route = createFileRoute("/_app/settings")({
|
||||||
component: SettingsPage,
|
component: SettingsLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
const THEME_OPTIONS: {
|
// Account-level settings, tabbed like project settings. Personal = the
|
||||||
value: ThemePreference;
|
// signed-in user (theme, API keys, analytics); Organization = the active org
|
||||||
label: string;
|
// (team). Billing keeps its own page — it's linked from paywalls all over.
|
||||||
icon: typeof Sun;
|
function SettingsLayout() {
|
||||||
}[] = [
|
const tabs = [
|
||||||
{ value: "system", label: "System", icon: Monitor },
|
{ to: "/settings" as const, label: "Personal", exact: true },
|
||||||
{ value: "light", label: "Light", icon: Sun },
|
// Self-host has no memberships — the organization tab would 404.
|
||||||
{ value: "dark", label: "Dark", icon: Moon },
|
...(isHostedClientAuthMode()
|
||||||
|
? [{ to: "/settings/organization" as const, label: "Organization" }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function SettingsPage() {
|
|
||||||
const isHosted = isHostedClientAuthMode();
|
|
||||||
const { themePreference, setThemePreference } = useThemePreference();
|
|
||||||
const { data: session, isPending: isSessionPending } = useSession();
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
|
|
||||||
|
|
||||||
async function updateAnalyticsPreference(enabled: boolean) {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const result = await authClient.updateUser({
|
|
||||||
analyticsOptedOut: !enabled,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
toast.error("We couldn't update your analytics setting.");
|
|
||||||
} else {
|
|
||||||
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("We couldn't update your analytics setting.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
|
<div className="h-full overflow-auto bg-base-100">
|
||||||
<div className="mx-auto max-w-3xl space-y-10">
|
<div className="mx-auto w-full max-w-4xl space-y-8 p-4 py-8 pb-24 sm:p-6 md:py-12 md:pb-12">
|
||||||
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
|
<div role="tablist" className="tabs tabs-border">
|
||||||
<section className="space-y-3">
|
{tabs.map((tab) => (
|
||||||
<h2 className="text-sm font-medium text-base-content/50">
|
<Link
|
||||||
Appearance
|
key={tab.to}
|
||||||
</h2>
|
role="tab"
|
||||||
<div className="flex items-center justify-between gap-6">
|
to={tab.to}
|
||||||
<span className="text-sm">Theme</span>
|
activeOptions={{ exact: tab.exact ?? false }}
|
||||||
<div
|
className="tab"
|
||||||
role="radiogroup"
|
activeProps={{
|
||||||
aria-label="Theme preference"
|
className: "tab-active",
|
||||||
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
|
"aria-selected": true,
|
||||||
>
|
|
||||||
{THEME_OPTIONS.map((option) => {
|
|
||||||
const isActive = option.value === themePreference;
|
|
||||||
const Icon = option.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={isActive}
|
|
||||||
aria-label={option.label}
|
|
||||||
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
|
|
||||||
isActive
|
|
||||||
? "bg-base-100 text-base-content shadow-sm"
|
|
||||||
: "text-base-content/50 hover:text-base-content/80"
|
|
||||||
}`}
|
|
||||||
onClick={() => setThemePreference(option.value)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{isHosted ? (
|
|
||||||
<>
|
|
||||||
<ApiKeySettings />
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-sm font-medium text-base-content/50">
|
|
||||||
Analytics
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-start justify-between gap-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm">Help improve OpenSEO</p>
|
|
||||||
<p className="mt-1 text-sm text-base-content/60">
|
|
||||||
Share analytics and usage data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="toggle toggle-primary"
|
|
||||||
checked={analyticsEnabled}
|
|
||||||
disabled={isSessionPending || isSaving || !session?.user}
|
|
||||||
onChange={(event) => {
|
|
||||||
void updateAnalyticsPreference(event.currentTarget.checked);
|
|
||||||
}}
|
}}
|
||||||
aria-label="Enable product analytics"
|
inactiveProps={{ "aria-selected": false }}
|
||||||
/>
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h2 className="text-sm font-medium text-base-content/50">About</h2>
|
|
||||||
<div className="flex items-center justify-between gap-6">
|
|
||||||
<span className="text-sm">Version</span>
|
|
||||||
<span className="font-mono text-sm text-base-content/60">
|
|
||||||
v{version}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
129
src/routes/_app/settings/index.tsx
Normal file
129
src/routes/_app/settings/index.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
||||||
|
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||||
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { version } from "../../../../package.json";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_app/settings/")({
|
||||||
|
component: PersonalSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const THEME_OPTIONS: {
|
||||||
|
value: ThemePreference;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
}[] = [
|
||||||
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
|
{ value: "light", label: "Light", icon: Sun },
|
||||||
|
{ value: "dark", label: "Dark", icon: Moon },
|
||||||
|
];
|
||||||
|
|
||||||
|
function PersonalSettings() {
|
||||||
|
const isHosted = isHostedClientAuthMode();
|
||||||
|
const { themePreference, setThemePreference } = useThemePreference();
|
||||||
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
|
||||||
|
|
||||||
|
async function updateAnalyticsPreference(enabled: boolean) {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.updateUser({
|
||||||
|
analyticsOptedOut: !enabled,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
toast.error("We couldn't update your analytics setting.");
|
||||||
|
} else {
|
||||||
|
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("We couldn't update your analytics setting.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">Appearance</h2>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-sm">Theme</span>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Theme preference"
|
||||||
|
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const isActive = option.value === themePreference;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-base-100 text-base-content shadow-sm"
|
||||||
|
: "text-base-content/50 hover:text-base-content/80"
|
||||||
|
}`}
|
||||||
|
onClick={() => setThemePreference(option.value)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isHosted ? (
|
||||||
|
<>
|
||||||
|
<ApiKeySettings />
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">
|
||||||
|
Analytics
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">Help improve OpenSEO</p>
|
||||||
|
<p className="mt-1 text-sm text-base-content/60">
|
||||||
|
Share analytics and usage data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary"
|
||||||
|
checked={analyticsEnabled}
|
||||||
|
disabled={isSessionPending || isSaving || !session?.user}
|
||||||
|
onChange={(event) => {
|
||||||
|
void updateAnalyticsPreference(event.currentTarget.checked);
|
||||||
|
}}
|
||||||
|
aria-label="Enable product analytics"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">About</h2>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-sm">Version</span>
|
||||||
|
<span className="font-mono text-sm text-base-content/60">
|
||||||
|
v{version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/routes/_app/settings/organization.tsx
Normal file
14
src/routes/_app/settings/organization.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
|
import { TeamSettings } from "@/client/features/team/TeamSettings";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_app/settings/organization")({
|
||||||
|
// Self-host has no memberships or invitations — the better-auth HTTP
|
||||||
|
// surface isn't even mounted there.
|
||||||
|
beforeLoad: () => {
|
||||||
|
if (!isHostedClientAuthMode()) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: TeamSettings,
|
||||||
|
});
|
||||||
9
src/routes/_app/team.tsx
Normal file
9
src/routes/_app/team.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
// The old standalone Team page moved into Settings → Organization; keep the
|
||||||
|
// URL working for bookmarks.
|
||||||
|
export const Route = createFileRoute("/_app/team")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/settings/organization" });
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -17,7 +17,7 @@ const SCOPES = [
|
|||||||
{
|
{
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
label: "Act on your behalf via MCP",
|
label: "Act on your behalf via MCP",
|
||||||
description: "Run tools and write results back to your workspace.",
|
description: "Run tools and write results back to your organization.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
|||||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
|
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
|
||||||
import {
|
import {
|
||||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||||
AUTUMN_PAID_PLAN_ID,
|
AUTUMN_PAID_PLAN_ID,
|
||||||
@ -59,6 +60,10 @@ function SubscribePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Checkout is owner-only; other members hitting the paywall are pointed at
|
||||||
|
// their organization owner instead of a Subscribe button that would 403.
|
||||||
|
const canManageBilling = useCanManageBilling();
|
||||||
|
|
||||||
// Read managed access from the already-loaded Autumn customer (local, no API
|
// Read managed access from the already-loaded Autumn customer (local, no API
|
||||||
// call) instead of a separate server round-trip. Self-hosted has no Autumn
|
// call) instead of a separate server round-trip. Self-hosted has no Autumn
|
||||||
// customer, so mirror the server's "always granted" behavior there.
|
// customer, so mirror the server's "always granted" behavior there.
|
||||||
@ -268,6 +273,7 @@ function SubscribePage() {
|
|||||||
|
|
||||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||||
|
|
||||||
|
{canManageBilling ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
className="btn btn-soft w-full"
|
||||||
disabled={isAttaching}
|
disabled={isAttaching}
|
||||||
@ -275,6 +281,12 @@ function SubscribePage() {
|
|||||||
>
|
>
|
||||||
{isAttaching ? "Redirecting..." : "Subscribe"}
|
{isAttaching ? "Redirecting..." : "Subscribe"}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Only the organization owner can subscribe. Ask them to upgrade this
|
||||||
|
organization.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-center text-xs text-base-content/50">
|
<p className="text-center text-xs text-base-content/50">
|
||||||
<span
|
<span
|
||||||
|
|||||||
238
src/routes/accept-invitation.$id.tsx
Normal file
238
src/routes/accept-invitation.$id.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/accept-invitation/$id")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
if (!isHostedClientAuthMode()) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AcceptInvitationPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AcceptInvitationPage() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell>
|
||||||
|
{isSessionPending ? null : session?.user ? (
|
||||||
|
<InvitationCard invitationId={id} userEmail={session.user.email} />
|
||||||
|
) : (
|
||||||
|
<SignedOutInvitationCard invitationId={id} />
|
||||||
|
)}
|
||||||
|
</AuthPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInvitation requires a session matching the invited email, so a
|
||||||
|
// logged-out visitor gets a generic shell — no invitation details are
|
||||||
|
// exposed pre-auth by design.
|
||||||
|
function SignedOutInvitationCard({ invitationId }: { invitationId: string }) {
|
||||||
|
const redirect = `/accept-invitation/${invitationId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageCard title="You’re invited">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
You’ve been invited to join an organization on OpenSEO. Sign in
|
||||||
|
with the email address that received the invitation to accept it.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/sign-up"
|
||||||
|
search={{ redirect }}
|
||||||
|
className="btn btn-soft w-full"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/sign-in"
|
||||||
|
search={{ redirect }}
|
||||||
|
className="btn btn-ghost w-full"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationCard({
|
||||||
|
invitationId,
|
||||||
|
userEmail,
|
||||||
|
}: {
|
||||||
|
invitationId: string;
|
||||||
|
userEmail: string;
|
||||||
|
}) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [declined, setDeclined] = useState(false);
|
||||||
|
|
||||||
|
const invitationQuery = useQuery({
|
||||||
|
queryKey: ["invitation", invitationId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await authClient.organization.getInvitation({
|
||||||
|
query: { id: invitationId },
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message || "Invitation not found");
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAccept() {
|
||||||
|
setActionError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const accepted = await authClient.organization.acceptInvitation({
|
||||||
|
invitationId,
|
||||||
|
});
|
||||||
|
if (accepted.error) {
|
||||||
|
setActionError(
|
||||||
|
accepted.error.message || "We couldn't accept the invitation.",
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting updates the session row but not the session cookie cache;
|
||||||
|
// setActive refreshes the cookie so the app opens in the joined org
|
||||||
|
// immediately instead of after the cache expires.
|
||||||
|
await authClient.organization.setActive({
|
||||||
|
organizationId: accepted.data.invitation.organizationId,
|
||||||
|
});
|
||||||
|
captureClientEvent("team:invitation_accept");
|
||||||
|
// Full navigation: every cached query in this tab belongs to the old
|
||||||
|
// workspace.
|
||||||
|
window.location.assign("/");
|
||||||
|
} catch {
|
||||||
|
setActionError("We couldn't accept the invitation. Please try again.");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecline() {
|
||||||
|
setActionError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.organization.rejectInvitation({
|
||||||
|
invitationId,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setActionError(
|
||||||
|
result.error.message || "We couldn't decline the invitation.",
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureClientEvent("team:invitation_decline");
|
||||||
|
setDeclined(true);
|
||||||
|
} catch {
|
||||||
|
setActionError("We couldn't decline the invitation. Please try again.");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<AuthPageCard title="Checking invitation...">
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<span className="loading loading-spinner loading-md" />
|
||||||
|
</div>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationQuery.isError) {
|
||||||
|
return (
|
||||||
|
<AuthPageCard title="Invitation unavailable">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
This invitation may have expired, been canceled, or belong to a
|
||||||
|
different email address. You’re signed in as{" "}
|
||||||
|
<span className="font-medium" data-ph-mask>
|
||||||
|
{userEmail}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
If the invitation was sent to another address, sign out and sign back
|
||||||
|
in with that email. Otherwise ask your teammate to send a new invite.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-soft w-full"
|
||||||
|
onClick={() => {
|
||||||
|
// Signs out, then lands on sign-in with a redirect back to this
|
||||||
|
// invitation (staying signed in would bounce straight back here).
|
||||||
|
signOutAndRedirect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use a different account
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="btn btn-ghost w-full">
|
||||||
|
Go to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declined) {
|
||||||
|
return (
|
||||||
|
<AuthPageCard title="Invitation declined">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
You declined the invitation to join{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{invitationQuery.data.organizationName}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-ghost w-full">
|
||||||
|
Go to dashboard
|
||||||
|
</Link>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageCard title="Join organization">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
<span className="font-medium" data-ph-mask>
|
||||||
|
{invitationQuery.data.inviterEmail}
|
||||||
|
</span>{" "}
|
||||||
|
invited you to join{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{invitationQuery.data.organizationName}
|
||||||
|
</span>{" "}
|
||||||
|
on OpenSEO.
|
||||||
|
</p>
|
||||||
|
{actionError ? <p className="text-sm text-error">{actionError}</p> : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-soft w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => void handleAccept()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Joining..." : "Accept invitation"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => void handleDecline()}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,29 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import type { autumnHandler } from "autumn-js/fetch";
|
import type { autumnHandler } from "autumn-js/fetch";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
||||||
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||||
|
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||||
|
|
||||||
|
// Autumn routes any org member may call: balance/customer reads that power
|
||||||
|
// credit meters and usage views. Every other route (attach, updateSubscription,
|
||||||
|
// openCustomerPortal, setupPayment, referrals, previews, ...) changes the
|
||||||
|
// org's subscription or payment state and is owner-only — unknown/new routes
|
||||||
|
// fail closed into the owner-only branch.
|
||||||
|
const MEMBER_READABLE_AUTUMN_ROUTES = new Set([
|
||||||
|
"getOrCreateCustomer",
|
||||||
|
"getEntity",
|
||||||
|
"listPlans",
|
||||||
|
"listEvents",
|
||||||
|
"aggregateEvents",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The caller's context is resolved exactly once per request, in
|
||||||
|
// handleAutumnRequest, and shared with Autumn's identify callback through this
|
||||||
|
// map. Resolving twice (identify re-reads the same headers) would let an
|
||||||
|
// active-org switch or membership change between the two reads authorize
|
||||||
|
// against one organization but bill another.
|
||||||
|
const contextByRequest = new WeakMap<Request, EnsuredUserContext>();
|
||||||
|
|
||||||
let handlerPromise: Promise<ReturnType<typeof autumnHandler>> | undefined;
|
let handlerPromise: Promise<ReturnType<typeof autumnHandler>> | undefined;
|
||||||
|
|
||||||
@ -13,7 +35,14 @@ function loadHandler() {
|
|||||||
({ autumnHandler }) =>
|
({ autumnHandler }) =>
|
||||||
autumnHandler({
|
autumnHandler({
|
||||||
identify: async (request) => {
|
identify: async (request) => {
|
||||||
const context = await resolveHostedContext(request.headers);
|
const context = contextByRequest.get(request);
|
||||||
|
// identify is only reachable via handleAutumnRequest, which resolves
|
||||||
|
// first — fail closed rather than re-resolving if that ever breaks.
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"Autumn identify called without a resolved context",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customerId: context.organizationId,
|
customerId: context.organizationId,
|
||||||
@ -30,6 +59,31 @@ async function handleAutumnRequest(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await resolveHostedContext(request.headers);
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ message: "Authentication required.", code: "unauthenticated" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contextByRequest.set(request, context);
|
||||||
|
|
||||||
|
const route = new URL(request.url).pathname.split("/").pop() ?? "";
|
||||||
|
if (
|
||||||
|
!MEMBER_READABLE_AUTUMN_ROUTES.has(route) &&
|
||||||
|
!hasOrgPermission(context.role, { billing: ["manage"] })
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message: "Only the organization owner can manage billing.",
|
||||||
|
code: "billing_owner_required",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (await loadHandler())(request);
|
return (await loadHandler())(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
src/server/auth/default-hosted-organization.test.ts
Normal file
73
src/server/auth/default-hosted-organization.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolveSignInHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getLastActiveOrganizationId: vi.fn(),
|
||||||
|
getMembership: vi.fn(),
|
||||||
|
findNewestMembershipForUser: vi.fn(),
|
||||||
|
findFirstOrganizationIdForUser: vi.fn(),
|
||||||
|
getHostedUser: vi.fn(),
|
||||||
|
hasPendingInvitationForEmail: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: mocks,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Referral pin repair is fire-and-forget KV bookkeeping; mocking it keeps
|
||||||
|
// `cloudflare:workers` out of this module graph.
|
||||||
|
vi.mock("@/server/referrals/dub", () => ({
|
||||||
|
markDubReferredOrganization: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("resolveSignInHostedOrganization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getLastActiveOrganizationId.mockResolvedValue(null);
|
||||||
|
mocks.findNewestMembershipForUser.mockResolvedValue(null);
|
||||||
|
mocks.getHostedUser.mockResolvedValue({
|
||||||
|
id: "user-1",
|
||||||
|
email: "invitee@example.com",
|
||||||
|
name: "Invitee",
|
||||||
|
});
|
||||||
|
mocks.getMembership.mockResolvedValue({ role: "owner" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The invite-signup invariant: a brand-new user with a pending invitation
|
||||||
|
// must NOT get a personal organization auto-minted at sign-in — accepting the
|
||||||
|
// invite should leave them in exactly the inviter's org.
|
||||||
|
it("defers organization creation while an invitation is pending", async () => {
|
||||||
|
mocks.hasPendingInvitationForEmail.mockResolvedValue(true);
|
||||||
|
const createOrganization = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveSignInHostedOrganization("user-1", createOrganization),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(createOrganization).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the default organization when no invitation is pending", async () => {
|
||||||
|
mocks.hasPendingInvitationForEmail.mockResolvedValue(false);
|
||||||
|
const createOrganization = vi.fn().mockResolvedValue({ id: "org-new" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveSignInHostedOrganization("user-1", createOrganization),
|
||||||
|
).resolves.toMatchObject({ organizationId: "org-new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// An existing membership always wins — the pending-invite check only
|
||||||
|
// applies to membership-less users, so inviting an existing user never
|
||||||
|
// hides their current organization.
|
||||||
|
it("returns the existing membership without checking invitations", async () => {
|
||||||
|
mocks.findNewestMembershipForUser.mockResolvedValue({
|
||||||
|
organizationId: "org-existing",
|
||||||
|
role: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveSignInHostedOrganization("user-1", vi.fn()),
|
||||||
|
).resolves.toEqual({ organizationId: "org-existing", role: "admin" });
|
||||||
|
|
||||||
|
expect(mocks.hasPendingInvitationForEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -20,12 +20,12 @@ type HostedOrganizationCreator = (
|
|||||||
|
|
||||||
function getDefaultHostedOrganizationName(user: HostedUser) {
|
function getDefaultHostedOrganizationName(user: HostedUser) {
|
||||||
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
|
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
|
||||||
return `${name}'s workspace`;
|
return `${name}'s organization`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultHostedOrganizationSlug(user: HostedUser) {
|
function getDefaultHostedOrganizationSlug(user: HostedUser) {
|
||||||
const slugSource =
|
const slugSource =
|
||||||
user.name?.trim() || user.email.split("@")[0] || "workspace";
|
user.name?.trim() || user.email.split("@")[0] || "organization";
|
||||||
const suffix = toHex(user.id).slice(0, 12);
|
const suffix = toHex(user.id).slice(0, 12);
|
||||||
return `${slugify(slugSource)}-${suffix}`;
|
return `${slugify(slugSource)}-${suffix}`;
|
||||||
}
|
}
|
||||||
@ -65,27 +65,133 @@ async function createDefaultHostedOrganization(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateDefaultHostedOrganization(
|
type ActiveHostedOrganization = {
|
||||||
|
organizationId: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findExistingActiveOrganization(
|
||||||
|
userId: string,
|
||||||
|
): Promise<ActiveHostedOrganization | null> {
|
||||||
|
const lastActiveOrganizationId =
|
||||||
|
await AuthRepository.getLastActiveOrganizationId(userId);
|
||||||
|
|
||||||
|
if (lastActiveOrganizationId) {
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
userId,
|
||||||
|
lastActiveOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
return {
|
||||||
|
organizationId: lastActiveOrganizationId,
|
||||||
|
role: membership.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestMembership =
|
||||||
|
await AuthRepository.findNewestMembershipForUser(userId);
|
||||||
|
|
||||||
|
if (newestMembership) {
|
||||||
|
return {
|
||||||
|
organizationId: newestMembership.organizationId,
|
||||||
|
role: newestMembership.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActiveHostedOrganization(
|
||||||
userId: string,
|
userId: string,
|
||||||
createOrganization: HostedOrganizationCreator,
|
createOrganization: HostedOrganizationCreator,
|
||||||
) {
|
): Promise<ActiveHostedOrganization> {
|
||||||
let organizationId =
|
|
||||||
await AuthRepository.findFirstOrganizationIdForUser(userId);
|
|
||||||
|
|
||||||
if (!organizationId) {
|
|
||||||
const hostedUser = await getHostedUser(userId);
|
const hostedUser = await getHostedUser(userId);
|
||||||
organizationId = await createDefaultHostedOrganization(
|
const organizationId = await createDefaultHostedOrganization(
|
||||||
hostedUser,
|
hostedUser,
|
||||||
createOrganization,
|
createOrganization,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Read the role instead of asserting "owner": a create that lost a race
|
||||||
|
// falls back to whatever membership won, which may not be owner-created.
|
||||||
|
const membership = await AuthRepository.getMembership(userId, organizationId);
|
||||||
|
return { organizationId, role: membership?.role ?? "owner" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// On every session, not just org creation: the signup-time referral pin can
|
// On every resolution, not just org creation: the signup-time referral pin
|
||||||
// land after the org exists (email verification from another location, or
|
// can land after the org exists (email verification from another location,
|
||||||
// BYPASS_EMAIL_VERIFICATION creating the session inside the signup
|
// or BYPASS_EMAIL_VERIFICATION creating the session inside the signup
|
||||||
// transaction before user.create.after hooks flush), so later logins repair
|
// transaction before user.create.after hooks flush), so later logins repair
|
||||||
// the org pin. No-ops without a user pin.
|
// the org pin. No-ops without a user pin, and only ever pins an org the user
|
||||||
|
// founded — an invitee's membership never counts.
|
||||||
|
async function repairDubReferralPin(userId: string) {
|
||||||
await markDubReferredOrganization(userId);
|
await markDubReferredOrganization(userId);
|
||||||
|
}
|
||||||
return organizationId;
|
|
||||||
|
// Picks the org a hosted user should be working in: their last-active org if
|
||||||
|
// they still belong to it, else their most recently joined org (so a fresh
|
||||||
|
// invite acceptance beats the signup-minted personal organization), else a
|
||||||
|
// newly created default organization. Used by the stale-session fallback in
|
||||||
|
// resolveHostedContext.
|
||||||
|
export async function resolveActiveHostedOrganization(
|
||||||
|
userId: string,
|
||||||
|
createOrganization: HostedOrganizationCreator,
|
||||||
|
): Promise<ActiveHostedOrganization> {
|
||||||
|
const existing = await findExistingActiveOrganization(userId);
|
||||||
|
if (existing) {
|
||||||
|
await repairDubReferralPin(userId);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const created = await createActiveHostedOrganization(
|
||||||
|
userId,
|
||||||
|
createOrganization,
|
||||||
|
);
|
||||||
|
await repairDubReferralPin(userId);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as resolveActiveHostedOrganization, but never mints a default
|
||||||
|
// organization. Used by API-key authentication: a user with no memberships has
|
||||||
|
// no org to bill or authorize, so credentials should be rejected instead of
|
||||||
|
// silently spinning up a new workspace.
|
||||||
|
export async function resolveExistingActiveHostedOrganization(
|
||||||
|
userId: string,
|
||||||
|
): Promise<ActiveHostedOrganization | null> {
|
||||||
|
const existing = await findExistingActiveOrganization(userId);
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await repairDubReferralPin(userId);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-in (session-create hook) variant: same resolution, except a user with
|
||||||
|
// no memberships and a pending invitation gets NO auto-minted personal
|
||||||
|
// organization — someone who signs up from an invite link should end up in
|
||||||
|
// exactly the inviter's org, not that plus an empty personal one. Returns
|
||||||
|
// null in that window (the session carries no active org until they accept).
|
||||||
|
// Abandoning the invite flow self-heals: the next app request goes through
|
||||||
|
// resolveActiveHostedOrganization, which still creates a default organization.
|
||||||
|
export async function resolveSignInHostedOrganization(
|
||||||
|
userId: string,
|
||||||
|
createOrganization: HostedOrganizationCreator,
|
||||||
|
): Promise<ActiveHostedOrganization | null> {
|
||||||
|
const existing = await findExistingActiveOrganization(userId);
|
||||||
|
if (existing) {
|
||||||
|
await repairDubReferralPin(userId);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostedUser = await getHostedUser(userId);
|
||||||
|
if (await AuthRepository.hasPendingInvitationForEmail(hostedUser.email)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createActiveHostedOrganization(
|
||||||
|
userId,
|
||||||
|
createOrganization,
|
||||||
|
);
|
||||||
|
await repairDubReferralPin(userId);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const SHARED_WORKSPACE_ORGANIZATION_ID = "shared-workspace";
|
|||||||
export async function ensureSharedWorkspaceOrganization() {
|
export async function ensureSharedWorkspaceOrganization() {
|
||||||
await AuthRepository.upsertDelegatedOrganization({
|
await AuthRepository.upsertDelegatedOrganization({
|
||||||
id: SHARED_WORKSPACE_ORGANIZATION_ID,
|
id: SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||||
name: "Shared workspace",
|
name: "Shared organization",
|
||||||
slug: SHARED_WORKSPACE_ORGANIZATION_ID,
|
slug: SHARED_WORKSPACE_ORGANIZATION_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ function getDelegatedOrganizationId(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDelegatedOrganizationName(email: string, userId: string) {
|
function getDelegatedOrganizationName(email: string, userId: string) {
|
||||||
return `${email.split("@")[0] || userId} workspace`;
|
return `${email.split("@")[0] || userId} organization`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDelegatedOrganizationSlug(email: string, userId: string) {
|
function getDelegatedOrganizationSlug(email: string, userId: string) {
|
||||||
|
|||||||
52
src/server/auth/invitation-send-limit.ts
Normal file
52
src/server/auth/invitation-send-limit.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
|
// Daily caps on invitation *emails*. The org plugin's invitationLimit bounds
|
||||||
|
// pending rows (20), but re-sends to an already-invited address are unbounded
|
||||||
|
// there — this is the bound on actual sends. KV is the only counter that
|
||||||
|
// holds across Workers isolates; the fixed UTC-day window keys expire on
|
||||||
|
// their own. KV writes race under concurrency, so treat these as abuse
|
||||||
|
// bounds, not exact quotas.
|
||||||
|
const PER_ADDRESS_DAILY_LIMIT = 5;
|
||||||
|
const PER_ORG_DAILY_LIMIT = 50;
|
||||||
|
const WINDOW_TTL_SECONDS = 60 * 60 * 24;
|
||||||
|
|
||||||
|
async function bumpDailyCounter(key: string, limit: number) {
|
||||||
|
const count = Number((await env.KV.get(key)) ?? "0");
|
||||||
|
if (count >= limit) return false;
|
||||||
|
await env.KV.put(key, String(count + 1), {
|
||||||
|
expirationTtl: WINDOW_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeInvitationSendBudget(
|
||||||
|
organizationId: string,
|
||||||
|
email: string,
|
||||||
|
) {
|
||||||
|
const day = new Date().toISOString().slice(0, 10);
|
||||||
|
const address = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(await bumpDailyCounter(
|
||||||
|
`invite-sends:${organizationId}:${day}`,
|
||||||
|
PER_ORG_DAILY_LIMIT,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new AppError(
|
||||||
|
"RATE_LIMITED",
|
||||||
|
"This organization has reached its daily invitation limit.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await bumpDailyCounter(
|
||||||
|
`invite-sends:${organizationId}:${address}:${day}`,
|
||||||
|
PER_ADDRESS_DAILY_LIMIT,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new AppError(
|
||||||
|
"RATE_LIMITED",
|
||||||
|
"This address has already received several invitations today.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/server/auth/invited-member.ts
Normal file
37
src/server/auth/invited-member.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import { userOnboardingAnswers } from "@/db/schema";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
|
||||||
|
// Runs after an invitation is accepted: point the user's next sign-in at the
|
||||||
|
// org they just joined, and skip first-run onboarding — the wizard configures
|
||||||
|
// the org's project (domain, market) and spends org credits, which must not be
|
||||||
|
// re-run against an already-configured workspace.
|
||||||
|
export async function onInvitationAccepted(input: {
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
}) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await AuthRepository.setLastActiveOrganization(
|
||||||
|
input.userId,
|
||||||
|
input.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(userOnboardingAnswers)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
completedAt: now,
|
||||||
|
gscNudgeDismissedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userOnboardingAnswers.userId,
|
||||||
|
set: {
|
||||||
|
completedAt: now,
|
||||||
|
gscNudgeDismissedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/server/auth/org-gate.ts
Normal file
20
src/server/auth/org-gate.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
hasOrgPermission,
|
||||||
|
type OrgPermissionRequest,
|
||||||
|
} from "@/lib/org-permissions";
|
||||||
|
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
|
// Server-side org-role gate for app resources (billing/project/integration).
|
||||||
|
// Zero-I/O: the role was already resolved from the member row by ensure-user.
|
||||||
|
export function requireOrgPermission(
|
||||||
|
context: Pick<EnsuredUserContext, "role">,
|
||||||
|
permissions: OrgPermissionRequest,
|
||||||
|
) {
|
||||||
|
if (!hasOrgPermission(context.role, permissions)) {
|
||||||
|
throw new AppError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"Your organization role does not allow this action.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ export function slugify(value: string) {
|
|||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 48);
|
.slice(0, 48);
|
||||||
|
|
||||||
return slug || "workspace";
|
return slug || "organization";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHex(value: string) {
|
export function toHex(value: string) {
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
import { aliasedTable, and, asc, eq, lt, notExists } from "drizzle-orm";
|
import {
|
||||||
|
aliasedTable,
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gt,
|
||||||
|
lt,
|
||||||
|
notExists,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { member, organization, user as authUser } from "@/db/schema";
|
import {
|
||||||
|
invitation,
|
||||||
|
member,
|
||||||
|
organization,
|
||||||
|
user as authUser,
|
||||||
|
} from "@/db/schema";
|
||||||
|
|
||||||
type DelegatedOrganizationInput = {
|
type DelegatedOrganizationInput = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -80,9 +95,94 @@ async function getHostedUser(userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The per-request membership check: session.activeOrganizationId is only an
|
||||||
|
// identity hint, this row is the authorization fact. Returns null when the
|
||||||
|
// user is not (or no longer) a member.
|
||||||
|
async function getMembership(userId: string, organizationId: string) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({ role: member.role })
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return membership ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback active-org choice when there is no valid last-active pointer: the
|
||||||
|
// most recently joined org, so a just-accepted invitation wins over the
|
||||||
|
// signup-minted personal workspace.
|
||||||
|
async function findNewestMembershipForUser(userId: string) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({ organizationId: member.organizationId, role: member.role })
|
||||||
|
.from(member)
|
||||||
|
.where(eq(member.userId, userId))
|
||||||
|
.orderBy(desc(member.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return membership ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMembershipsForUser(userId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
organizationName: organization.name,
|
||||||
|
role: member.role,
|
||||||
|
})
|
||||||
|
.from(member)
|
||||||
|
.innerJoin(organization, eq(member.organizationId, organization.id))
|
||||||
|
.where(eq(member.userId, userId))
|
||||||
|
.orderBy(asc(member.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive on purpose: better-auth lowercases the address when it
|
||||||
|
// mails an invite, but the stored row keeps whatever the inviter typed.
|
||||||
|
async function hasPendingInvitationForEmail(email: string) {
|
||||||
|
const [pending] = await db
|
||||||
|
.select({ id: invitation.id })
|
||||||
|
.from(invitation)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sql`lower(${invitation.email})`, email.trim().toLowerCase()),
|
||||||
|
eq(invitation.status, "pending"),
|
||||||
|
gt(invitation.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return pending !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLastActiveOrganizationId(userId: string) {
|
||||||
|
const record = await db.query.user.findFirst({
|
||||||
|
columns: { lastActiveOrganizationId: true },
|
||||||
|
where: eq(authUser.id, userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return record?.lastActiveOrganizationId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLastActiveOrganization(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(authUser)
|
||||||
|
.set({ lastActiveOrganizationId: organizationId })
|
||||||
|
.where(eq(authUser.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
export const AuthRepository = {
|
export const AuthRepository = {
|
||||||
upsertDelegatedOrganization,
|
upsertDelegatedOrganization,
|
||||||
findFirstOrganizationIdForUser,
|
findFirstOrganizationIdForUser,
|
||||||
findFirstFoundedOrganizationIdForUser,
|
findFirstFoundedOrganizationIdForUser,
|
||||||
|
findNewestMembershipForUser,
|
||||||
|
getMembership,
|
||||||
|
listMembershipsForUser,
|
||||||
|
getLastActiveOrganizationId,
|
||||||
|
setLastActiveOrganization,
|
||||||
getHostedUser,
|
getHostedUser,
|
||||||
|
hasPendingInvitationForEmail,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -128,6 +128,37 @@ export async function sendHostedVerificationEmail({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendHostedInvitationEmail({
|
||||||
|
email,
|
||||||
|
inviteUrl,
|
||||||
|
organizationName,
|
||||||
|
inviterName,
|
||||||
|
inviterEmail,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
inviteUrl: string;
|
||||||
|
organizationName: string;
|
||||||
|
inviterName: string;
|
||||||
|
inviterEmail: string;
|
||||||
|
}) {
|
||||||
|
// Not part of getHostedAuthEmailConfig(): that trio gates hasHostedAuthConfig
|
||||||
|
// and adding a new required var there would brick existing deployments.
|
||||||
|
const apiKey = getRequiredEnv("LOOPS_API_KEY");
|
||||||
|
const templateId = getRequiredEnv("LOOPS_TRANSACTIONAL_INVITATION_ID");
|
||||||
|
await sendLoopsTransactionalEmail({
|
||||||
|
apiKey,
|
||||||
|
email,
|
||||||
|
transactionalId: templateId,
|
||||||
|
dataVariables: {
|
||||||
|
appName: "OpenSEO",
|
||||||
|
inviteUrl,
|
||||||
|
organizationName,
|
||||||
|
inviterName,
|
||||||
|
inviterEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendHostedPasswordResetEmail({
|
export async function sendHostedPasswordResetEmail({
|
||||||
email,
|
email,
|
||||||
resetUrl,
|
resetUrl,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
auditIssues,
|
auditIssues,
|
||||||
auditLighthouseResults,
|
auditLighthouseResults,
|
||||||
auditPages,
|
auditPages,
|
||||||
|
projects,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { executeInBatches } from "@/db/runBatch";
|
import { executeInBatches } from "@/db/runBatch";
|
||||||
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
||||||
@ -340,15 +341,19 @@ async function getAuditsByProject(projectId: string) {
|
|||||||
return rows.map(({ audit }) => audit);
|
return rows.map(({ audit }) => audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuditUsageForUser(userId: string) {
|
// Org-scoped: the free-plan quota belongs to the org (the Autumn customer),
|
||||||
const rows = await db.query.audits.findMany({
|
// so usage must aggregate across every member — counting per starting user
|
||||||
where: eq(audits.startedByUserId, userId),
|
// would multiply the free ceiling by the member count.
|
||||||
columns: {
|
async function getAuditUsageForOrganization(organizationId: string) {
|
||||||
status: true,
|
const rows = await db
|
||||||
pagesTotal: true,
|
.select({
|
||||||
lighthouseTotal: true,
|
status: audits.status,
|
||||||
},
|
pagesTotal: audits.pagesTotal,
|
||||||
});
|
lighthouseTotal: audits.lighthouseTotal,
|
||||||
|
})
|
||||||
|
.from(audits)
|
||||||
|
.innerJoin(projects, eq(audits.projectId, projects.id))
|
||||||
|
.where(eq(projects.organizationId, organizationId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
capacityUnits: rows.reduce(
|
capacityUnits: rows.reduce(
|
||||||
@ -437,7 +442,7 @@ export const AuditRepository = {
|
|||||||
countBlockedPages,
|
countBlockedPages,
|
||||||
hasPagesForAudit,
|
hasPagesForAudit,
|
||||||
getAuditsByProject,
|
getAuditsByProject,
|
||||||
getAuditUsageForUser,
|
getAuditUsageForOrganization,
|
||||||
getAuditResultsForProject,
|
getAuditResultsForProject,
|
||||||
getLighthouseResultById,
|
getLighthouseResultById,
|
||||||
deleteAuditForProject,
|
deleteAuditForProject,
|
||||||
|
|||||||
@ -94,8 +94,11 @@ async function startAudit(input: {
|
|||||||
// pass the free tier's running-audits gate. Post-insert, each request sees
|
// pass the free tier's running-audits gate. Post-insert, each request sees
|
||||||
// at least its own row, so racers can't all slip under the limit; the
|
// at least its own row, so racers can't all slip under the limit; the
|
||||||
// losers roll back via the catch below. Racers at the boundary may all
|
// losers roll back via the catch below. Racers at the boundary may all
|
||||||
// abort — the user just retries.
|
// abort — the user just retries. Usage counts per ORGANIZATION, not per
|
||||||
const usage = await AuditRepository.getAuditUsageForUser(input.actorUserId);
|
// user: the free ceiling is the org's, so N members don't multiply it.
|
||||||
|
const usage = await AuditRepository.getAuditUsageForOrganization(
|
||||||
|
input.billingCustomer.organizationId,
|
||||||
|
);
|
||||||
if (usage.runningCount > limits.maxRunningAudits) {
|
if (usage.runningCount > limits.maxRunningAudits) {
|
||||||
throw new AppError("AUDIT_ALREADY_RUNNING");
|
throw new AppError("AUDIT_ALREADY_RUNNING");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
archiveProject,
|
archiveProject,
|
||||||
createProject,
|
createProject,
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
|
getProjectWithOrganization,
|
||||||
listArchivedProjects,
|
listArchivedProjects,
|
||||||
listProjects,
|
listProjects,
|
||||||
listProjectsEnsuringOne,
|
listProjectsEnsuringOne,
|
||||||
@ -22,4 +23,5 @@ export const ProjectService = {
|
|||||||
restoreProject,
|
restoreProject,
|
||||||
listArchivedProjects,
|
listArchivedProjects,
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
|
getProjectWithOrganization,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -244,3 +244,15 @@ export async function getProjectForOrganization(
|
|||||||
|
|
||||||
return mapProject(project);
|
return mapProject(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project lookup that reveals which org owns it, for user-scoped credentials
|
||||||
|
// (MCP API keys): the caller derives the org FROM the project and must then
|
||||||
|
// authorize the user's membership in that org before acting on the result.
|
||||||
|
export async function getProjectWithOrganization(projectId: string) {
|
||||||
|
const project = await ProjectRepository.getProjectById(projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
return {
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
project: mapProject(project),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { MCP_SCOPE } from "@/lib/oauth-resource";
|
import { MCP_SCOPE } from "@/lib/oauth-resource";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import type { ToolAuthContext } from "@/server/mcp/context";
|
import type { ToolAuthContext } from "@/server/mcp/context";
|
||||||
|
|
||||||
// SAM's read-only view of the project's shared memory. The block has no `set`
|
// SAM's read-only view of the project's shared memory. The block has no `set`
|
||||||
@ -267,7 +268,8 @@ export class SamChatAgent extends Think {
|
|||||||
// confirmed against a second Autumn read path before refusing — a
|
// confirmed against a second Autumn read path before refusing — a
|
||||||
// stale check reading here once locked a paying customer out of chat.
|
// stale check reading here once locked a paying customer out of chat.
|
||||||
const { organizationId } = ctx.project;
|
const { organizationId } = ctx.project;
|
||||||
if (await isHostedServerAuthMode()) {
|
const hosted = await isHostedServerAuthMode();
|
||||||
|
if (hosted) {
|
||||||
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
|
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
|
||||||
userId: ctx.row.userId,
|
userId: ctx.row.userId,
|
||||||
userEmail: ctx.userEmail,
|
userEmail: ctx.userEmail,
|
||||||
@ -285,10 +287,28 @@ export class SamChatAgent extends Think {
|
|||||||
const baseUrl =
|
const baseUrl =
|
||||||
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
|
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
|
||||||
"https://app.openseo.so";
|
"https://app.openseo.so";
|
||||||
|
// Delegated/self-host orgs have no member rows — implicit owner. In
|
||||||
|
// hosted mode a missing member row means the user was removed from the
|
||||||
|
// workspace; fail closed instead of letting the open socket keep
|
||||||
|
// owner-level tools (WebSockets authorize at connect time only, so this
|
||||||
|
// per-turn check is what actually revokes a removed member's chat).
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
ctx.row.userId,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
if (hosted && !membership) {
|
||||||
|
return this.refusalTurn(
|
||||||
|
"You no longer have access to this organization, so I can't continue this chat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const authContext: ToolAuthContext = {
|
const authContext: ToolAuthContext = {
|
||||||
userId: ctx.row.userId,
|
userId: ctx.row.userId,
|
||||||
userEmail: ctx.userEmail,
|
userEmail: ctx.userEmail,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
role: membership?.role ?? "owner",
|
||||||
|
// SAM sessions belong to one project's workspace; org context is
|
||||||
|
// fixed for the session, like an OAuth token's.
|
||||||
|
orgScope: "pinned",
|
||||||
baseUrl,
|
baseUrl,
|
||||||
clientId: null,
|
clientId: null,
|
||||||
scopes: [MCP_SCOPE],
|
scopes: [MCP_SCOPE],
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transpor
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
verifyApiKey: vi.fn(),
|
verifyApiKey: vi.fn(),
|
||||||
getHostedUser: vi.fn(),
|
getHostedUser: vi.fn(),
|
||||||
getOrCreateDefaultHostedOrganization: vi.fn(),
|
resolveExistingActiveHostedOrganization: vi.fn(),
|
||||||
recordMcpAuthorized: vi.fn(),
|
recordMcpAuthorized: vi.fn(),
|
||||||
handleAuthenticatedOpenSeoMcpRequest:
|
handleAuthenticatedOpenSeoMcpRequest:
|
||||||
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
|
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
|
||||||
@ -29,8 +29,8 @@ vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/auth/default-hosted-organization", () => ({
|
vi.mock("@/server/auth/default-hosted-organization", () => ({
|
||||||
getOrCreateDefaultHostedOrganization:
|
resolveExistingActiveHostedOrganization:
|
||||||
mocks.getOrCreateDefaultHostedOrganization,
|
mocks.resolveExistingActiveHostedOrganization,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/features/activation/mcpActivation", () => ({
|
vi.mock("@/server/features/activation/mcpActivation", () => ({
|
||||||
@ -63,7 +63,10 @@ describe("handleMcpApiKeyRequest", () => {
|
|||||||
email: "person@example.com",
|
email: "person@example.com",
|
||||||
name: "Person",
|
name: "Person",
|
||||||
});
|
});
|
||||||
mocks.getOrCreateDefaultHostedOrganization.mockResolvedValue("org-1");
|
mocks.resolveExistingActiveHostedOrganization.mockResolvedValue({
|
||||||
|
organizationId: "org-1",
|
||||||
|
role: "owner",
|
||||||
|
});
|
||||||
mocks.recordMcpAuthorized.mockResolvedValue(undefined);
|
mocks.recordMcpAuthorized.mockResolvedValue(undefined);
|
||||||
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
|
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
|
||||||
new Response("mcp response"),
|
new Response("mcp response"),
|
||||||
@ -84,9 +87,8 @@ describe("handleMcpApiKeyRequest", () => {
|
|||||||
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
|
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
|
||||||
body: { key: "oseo_secret" },
|
body: { key: "oseo_secret" },
|
||||||
});
|
});
|
||||||
expect(mocks.getOrCreateDefaultHostedOrganization).toHaveBeenCalledWith(
|
expect(mocks.resolveExistingActiveHostedOrganization).toHaveBeenCalledWith(
|
||||||
"user-1",
|
"user-1",
|
||||||
expect.any(Function),
|
|
||||||
);
|
);
|
||||||
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
|
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
|
||||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
|
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
|
||||||
@ -100,6 +102,10 @@ describe("handleMcpApiKeyRequest", () => {
|
|||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
userEmail: "person@example.com",
|
userEmail: "person@example.com",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
|
role: "owner",
|
||||||
|
// The key is user-scoped: project tools authorize per call via
|
||||||
|
// membership in the project's org, not this request-level org.
|
||||||
|
orgScope: "user",
|
||||||
scopes: [...MCP_OAUTH_SCOPES],
|
scopes: [...MCP_OAUTH_SCOPES],
|
||||||
clientId: "api_key",
|
clientId: "api_key",
|
||||||
baseUrl: "https://app.openseo.so",
|
baseUrl: "https://app.openseo.so",
|
||||||
@ -125,6 +131,27 @@ describe("handleMcpApiKeyRequest", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 403 when the user has no organization membership", async () => {
|
||||||
|
mocks.verifyApiKey.mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
error: null,
|
||||||
|
key: { referenceId: "user-1" },
|
||||||
|
});
|
||||||
|
mocks.resolveExistingActiveHostedOrganization.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await handleMcpApiKeyRequest(
|
||||||
|
request({ Authorization: "Bearer oseo_secret" }),
|
||||||
|
env,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response?.status).toBe(403);
|
||||||
|
await expect(response?.json()).resolves.toMatchObject({
|
||||||
|
error: "account_access_revoked",
|
||||||
|
});
|
||||||
|
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 401 for an invalid key without invoking the transport", async () => {
|
it("returns 401 for an invalid key without invoking the transport", async () => {
|
||||||
mocks.verifyApiKey.mockResolvedValue({
|
mocks.verifyApiKey.mockResolvedValue({
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
|
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
|
||||||
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
|
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
|
||||||
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
|
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { resolveExistingActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
|
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
|
||||||
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
|
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
|
||||||
@ -29,6 +29,7 @@ function apiKeyErrorResponse(
|
|||||||
|
|
||||||
const isLimited =
|
const isLimited =
|
||||||
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
|
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
|
||||||
|
const isForbidden = error?.code === "FORBIDDEN";
|
||||||
if (isLimited) {
|
if (isLimited) {
|
||||||
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
|
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
|
||||||
// published error type omits `details`, so narrow at runtime.
|
// published error type omits `details`, so narrow at runtime.
|
||||||
@ -49,13 +50,19 @@ function apiKeyErrorResponse(
|
|||||||
? error?.code === "RATE_LIMITED"
|
? error?.code === "RATE_LIMITED"
|
||||||
? "rate_limited"
|
? "rate_limited"
|
||||||
: "usage_exceeded"
|
: "usage_exceeded"
|
||||||
|
: isForbidden
|
||||||
|
? "account_access_revoked"
|
||||||
: "invalid_api_key";
|
: "invalid_api_key";
|
||||||
const description = isLimited
|
const description = isLimited
|
||||||
? typeof error?.message === "string"
|
? typeof error?.message === "string"
|
||||||
? error.message
|
? error.message
|
||||||
: "API key request limit reached"
|
: "API key request limit reached"
|
||||||
|
: isForbidden
|
||||||
|
? typeof error?.message === "string"
|
||||||
|
? error.message
|
||||||
|
: "This API key is no longer associated with an organization"
|
||||||
: "The provided API key is invalid, expired, or disabled";
|
: "The provided API key is invalid, expired, or disabled";
|
||||||
const status = isLimited ? 429 : 401;
|
const status = isLimited ? 429 : isForbidden ? 403 : 401;
|
||||||
|
|
||||||
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
|
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
|
||||||
// OAuth path); a 429 means we actually cut a caller off, so warn.
|
// OAuth path); a 429 means we actually cut a caller off, so warn.
|
||||||
@ -103,7 +110,7 @@ export async function handleMcpApiKeyRequest(
|
|||||||
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
|
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
|
||||||
// best-effort, which is all this needs to be: credits bound spend, this
|
// best-effort, which is all this needs to be: credits bound spend, this
|
||||||
// bounds runaway request volume.
|
// bounds runaway request volume.
|
||||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared in alchemy.run.ts; env stays unknown through the MCP handler chain
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared as a rate limiter in alchemy.run.ts; absent outside hosted prod
|
||||||
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
|
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
|
||||||
if (rateLimit) {
|
if (rateLimit) {
|
||||||
const { success } = await rateLimit.limit({ key: userId });
|
const { success } = await rateLimit.limit({ key: userId });
|
||||||
@ -119,22 +126,31 @@ export async function handleMcpApiKeyRequest(
|
|||||||
const user = await AuthRepository.getHostedUser(userId);
|
const user = await AuthRepository.getHostedUser(userId);
|
||||||
if (!user?.email) return apiKeyErrorResponse(null);
|
if (!user?.email) return apiKeyErrorResponse(null);
|
||||||
|
|
||||||
// API keys bill the user's default hosted workspace (their first org).
|
// API keys bill the user's active organization. Keys are user-scoped and
|
||||||
// The hosted product provisions exactly one org per user. Decided
|
// the org derives from the project each tool call names (project-level
|
||||||
// direction for multi-org: keys stay user-scoped and the org derives from
|
// authz) — not key→org binding. Fail closed if the user has no existing
|
||||||
// the project each tool call names (project-level authz) — not key→org
|
// memberships; we never mint a default organization for an API key.
|
||||||
// binding.
|
const resolved = await resolveExistingActiveHostedOrganization(userId);
|
||||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
if (!resolved) {
|
||||||
userId,
|
return apiKeyErrorResponse({
|
||||||
(body) => authApi.createOrganization({ body }),
|
code: "FORBIDDEN",
|
||||||
);
|
message: "API key is not associated with an organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { organizationId, role } = resolved;
|
||||||
|
|
||||||
// clientId "api_key" satisfies the hosted transport's fail-closed props
|
// clientId "api_key" satisfies the hosted transport's fail-closed props
|
||||||
// schema and counts these calls as external MCP clients in telemetry.
|
// schema and counts these calls as external MCP clients in telemetry.
|
||||||
|
// orgScope "user": the key itself is the credential, not a key→org
|
||||||
|
// binding — project-scoped tools authorize per call via the caller's
|
||||||
|
// membership in the project's org, and organizationId above is only the
|
||||||
|
// fallback for tools with no project argument.
|
||||||
const props = createWorkersOAuthMcpProps({
|
const props = createWorkersOAuthMcpProps({
|
||||||
userId,
|
userId,
|
||||||
userEmail: user.email,
|
userEmail: user.email,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
role,
|
||||||
|
orgScope: "user",
|
||||||
baseUrl: getHostedBaseUrl(),
|
baseUrl: getHostedBaseUrl(),
|
||||||
scopes: [...MCP_OAUTH_SCOPES],
|
scopes: [...MCP_OAUTH_SCOPES],
|
||||||
clientId: "api_key",
|
clientId: "api_key",
|
||||||
|
|||||||
@ -7,6 +7,18 @@ export type ToolAuthContext = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
// Org role for permission gates (owner/admin/member, comma-joined when
|
||||||
|
// multiple). Hosted: stamped per request from the member row in
|
||||||
|
// transport.ts. Self-host/delegated: one implicit user per org → "owner".
|
||||||
|
role: string;
|
||||||
|
// How tool calls bind to an organization. "pinned": the request's
|
||||||
|
// organizationId is the authorization boundary — OAuth tokens (org stamped
|
||||||
|
// at consent) and self-host. "user": the credential is user-scoped (API
|
||||||
|
// keys) — project-scoped tools derive the org from the project row and
|
||||||
|
// authorize via the caller's membership in THAT org, so one key works
|
||||||
|
// across every organization the user belongs to; organizationId is only the
|
||||||
|
// fallback context for the few tools with no project argument.
|
||||||
|
orgScope: "pinned" | "user";
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -23,6 +35,12 @@ const applicationAuthContextSchema = z.object({
|
|||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
userEmail: z.string().min(1),
|
userEmail: z.string().min(1),
|
||||||
organizationId: z.string().min(1),
|
organizationId: z.string().min(1),
|
||||||
|
// Absent from OAuth grant props (role is stamped per request by the hosted
|
||||||
|
// transport, never baked into tokens) and from delegated modes (implicit
|
||||||
|
// owner).
|
||||||
|
role: z.string().min(1).optional(),
|
||||||
|
// Absent everywhere except the API-key path; absent means "pinned".
|
||||||
|
orgScope: z.enum(["pinned", "user"]).optional(),
|
||||||
baseUrl: z.string().url(),
|
baseUrl: z.string().url(),
|
||||||
// Compatibility fallback until workers-oauth-provider supplies the verified
|
// Compatibility fallback until workers-oauth-provider supplies the verified
|
||||||
// context marker consumed by Agents SDK 0.20.x (the
|
// context marker consumed by Agents SDK 0.20.x (the
|
||||||
@ -75,10 +93,23 @@ export function createMcpToolContext(
|
|||||||
const authInfo = context.http?.authInfo;
|
const authInfo = context.http?.authInfo;
|
||||||
const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null;
|
const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null;
|
||||||
const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? [];
|
const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? [];
|
||||||
|
const orgScope = applicationAuth.orgScope ?? "pinned";
|
||||||
|
// Delegated/self-hosted modes have no member rows and a single implicit owner
|
||||||
|
// per org; "pinned" without a role means owner. API keys ("user" scope) must
|
||||||
|
// stamp the role from the user's active org membership in api-key-auth.ts.
|
||||||
|
const role =
|
||||||
|
applicationAuth.role ?? (orgScope === "pinned" ? "owner" : undefined);
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(
|
||||||
|
"MCP auth context is missing a role for a user-scoped credential",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
...applicationAuth,
|
...applicationAuth,
|
||||||
|
role,
|
||||||
|
orgScope,
|
||||||
clientId,
|
clientId,
|
||||||
scopes,
|
scopes,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -44,6 +44,8 @@ const authContext: ToolAuthContext = {
|
|||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
userEmail: "user@example.com",
|
userEmail: "user@example.com",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
|
role: "owner",
|
||||||
|
orgScope: "pinned",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
scopes: ["mcp"],
|
scopes: ["mcp"],
|
||||||
baseUrl: "https://app.openseo.so",
|
baseUrl: "https://app.openseo.so",
|
||||||
|
|||||||
@ -3,11 +3,20 @@ import { makeToolContext } from "@/server/mcp/tools/tool-test-support";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
getProjectForOrganization: vi.fn(),
|
getProjectForOrganization: vi.fn(),
|
||||||
|
getProjectWithOrganization: vi.fn(),
|
||||||
|
getMembership: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
ProjectService: {
|
ProjectService: {
|
||||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
|
getProjectWithOrganization: mocks.getProjectWithOrganization,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: {
|
||||||
|
getMembership: mocks.getMembership,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -55,6 +64,8 @@ describe("withMcpProjectAuth", () => {
|
|||||||
userId: "user_123",
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
userEmail: "alice@example.com",
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
role: "owner",
|
||||||
|
orgScope: "pinned",
|
||||||
clientId: "client_123",
|
clientId: "client_123",
|
||||||
scopes: ["mcp"],
|
scopes: ["mcp"],
|
||||||
},
|
},
|
||||||
@ -105,3 +116,77 @@ describe("withMcpProjectAuth", () => {
|
|||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User-scoped credentials (API keys): the org derives from the project and
|
||||||
|
// access is the caller's membership in that org — never the request's
|
||||||
|
// organizationId.
|
||||||
|
describe("withMcpProjectAuth with a user-scoped credential", () => {
|
||||||
|
const userScopedContext = makeToolContext({ orgScope: "user" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.getProjectWithOrganization.mockResolvedValue({
|
||||||
|
organizationId: "org_other",
|
||||||
|
project: {
|
||||||
|
id: "project_123",
|
||||||
|
name: "Test",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mocks.getMembership.mockResolvedValue({ role: "admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebinds auth and billing to the project's org and the member's role there", async () => {
|
||||||
|
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
|
||||||
|
const handler = vi.fn<
|
||||||
|
(
|
||||||
|
args: { projectId: string },
|
||||||
|
context: {
|
||||||
|
auth: { organizationId: string; role: string };
|
||||||
|
billing: { organizationId: string };
|
||||||
|
},
|
||||||
|
) => string
|
||||||
|
>(() => "ok");
|
||||||
|
|
||||||
|
await withMcpProjectAuth(handler)(
|
||||||
|
{ projectId: "project_123" },
|
||||||
|
userScopedContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.getMembership).toHaveBeenCalledWith("user_123", "org_other");
|
||||||
|
expect(mocks.getProjectForOrganization).not.toHaveBeenCalled();
|
||||||
|
const [, context] = handler.mock.calls[0];
|
||||||
|
expect(context.auth.organizationId).toBe("org_other");
|
||||||
|
expect(context.auth.role).toBe("admin");
|
||||||
|
expect(context.billing.organizationId).toBe("org_other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller has no membership in the project's org", async () => {
|
||||||
|
mocks.getMembership.mockResolvedValue(null);
|
||||||
|
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withMcpProjectAuth(handler)(
|
||||||
|
{ projectId: "project_123" },
|
||||||
|
userScopedContext,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("FORBIDDEN");
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown project without leaking whether it exists", async () => {
|
||||||
|
mocks.getProjectWithOrganization.mockResolvedValue(null);
|
||||||
|
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withMcpProjectAuth(handler)({ projectId: "missing" }, userScopedContext),
|
||||||
|
).rejects.toThrow("FORBIDDEN");
|
||||||
|
|
||||||
|
expect(mocks.getMembership).not.toHaveBeenCalled();
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
|
import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
|
||||||
@ -12,6 +13,34 @@ async function requireProjectAccess(
|
|||||||
) {
|
) {
|
||||||
const { baseUrl, ...auth } = toolContext.auth;
|
const { baseUrl, ...auth } = toolContext.auth;
|
||||||
|
|
||||||
|
// User-scoped credentials (API keys) have no org binding: derive the org
|
||||||
|
// from the project row, then authorize via the caller's membership in that
|
||||||
|
// org. The returned auth is rebound to the project's org + the member's
|
||||||
|
// role there, so billing and every downstream org read follow the project.
|
||||||
|
if (auth.orgScope === "user") {
|
||||||
|
const resolved = await ProjectService.getProjectWithOrganization(projectId);
|
||||||
|
const membership = resolved
|
||||||
|
? await AuthRepository.getMembership(auth.userId, resolved.organizationId)
|
||||||
|
: null;
|
||||||
|
// One FORBIDDEN for both unknown project and non-membership, so probing
|
||||||
|
// with foreign project ids can't distinguish "exists" from "no access".
|
||||||
|
if (!resolved || !membership) {
|
||||||
|
throw new AppError("FORBIDDEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectAuth = {
|
||||||
|
...auth,
|
||||||
|
organizationId: resolved.organizationId,
|
||||||
|
role: membership.role,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
auth: projectAuth,
|
||||||
|
baseUrl,
|
||||||
|
billing: buildBillingCustomer(projectAuth, projectId),
|
||||||
|
project: resolved.project,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Authorize the caller-supplied projectId against the token's organization.
|
// Authorize the caller-supplied projectId against the token's organization.
|
||||||
// Assert on the result instead of relying on the lookup throwing, so this
|
// Assert on the result instead of relying on the lookup throwing, so this
|
||||||
// stays a hard gate even if the service's error behavior ever changes.
|
// stays a hard gate even if the service's error behavior ever changes.
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { makeToolContext } from "./tool-test-support";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
|
listMembershipsForUser: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
@ -12,6 +13,12 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: {
|
||||||
|
listMembershipsForUser: mocks.listMembershipsForUser,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const toolContext = makeToolContext();
|
const toolContext = makeToolContext();
|
||||||
|
|
||||||
describe("create_project MCP tool", () => {
|
describe("create_project MCP tool", () => {
|
||||||
@ -94,4 +101,83 @@ describe("create_project MCP tool", () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.createProject).not.toHaveBeenCalled();
|
expect(mocks.createProject).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a foreign organizationId on a organization-bound (pinned) connection", async () => {
|
||||||
|
await expect(
|
||||||
|
createProjectTool.handler(
|
||||||
|
{ name: "Acme", organizationId: "org_other" },
|
||||||
|
toolContext,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("bound to a single organization");
|
||||||
|
expect(mocks.createProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a member role from creating projects", async () => {
|
||||||
|
const memberContext = makeToolContext({ role: "member" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createProjectTool.handler({ name: "Acme" }, memberContext),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(mocks.createProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User-scoped credentials (API keys): the target organization must be
|
||||||
|
// unambiguous — a single membership resolves implicitly, multiple require an
|
||||||
|
// explicit, membership-checked organizationId confirmed with the user.
|
||||||
|
describe("create_project with a user-scoped credential", () => {
|
||||||
|
const userScopedContext = makeToolContext({ orgScope: "user" });
|
||||||
|
const memberships = [
|
||||||
|
{ organizationId: "org_a", organizationName: "Alpha", role: "owner" },
|
||||||
|
{ organizationId: "org_b", organizationName: "Beta", role: "admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.createProject.mockResolvedValue({
|
||||||
|
id: "project_new",
|
||||||
|
name: "Acme",
|
||||||
|
domain: null,
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
|
mocks.listMembershipsForUser.mockResolvedValue(memberships);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors with the organization list when no organizationId is given and the user has several", async () => {
|
||||||
|
await expect(
|
||||||
|
createProjectTool.handler({ name: "Acme" }, userScopedContext),
|
||||||
|
).rejects.toThrow(/org_a {2}Alpha[\s\S]*org_b {2}Beta/);
|
||||||
|
expect(mocks.createProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates in the named organization when the user is a member of it", async () => {
|
||||||
|
await createProjectTool.handler(
|
||||||
|
{ name: "Acme", organizationId: "org_b" },
|
||||||
|
userScopedContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.createProject).toHaveBeenCalledWith("org_b", {
|
||||||
|
name: "Acme",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a organizationId the user is not a member of", async () => {
|
||||||
|
await expect(
|
||||||
|
createProjectTool.handler(
|
||||||
|
{ name: "Acme", organizationId: "org_stranger" },
|
||||||
|
userScopedContext,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("not a member");
|
||||||
|
expect(mocks.createProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves implicitly when the user belongs to exactly one organization", async () => {
|
||||||
|
mocks.listMembershipsForUser.mockResolvedValue([memberships[0]]);
|
||||||
|
|
||||||
|
await createProjectTool.handler({ name: "Acme" }, userScopedContext);
|
||||||
|
|
||||||
|
expect(mocks.createProject).toHaveBeenCalledWith("org_a", {
|
||||||
|
name: "Acme",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
@ -33,10 +35,68 @@ const inputSchema = {
|
|||||||
.describe(
|
.describe(
|
||||||
'Optional language code (e.g. "en", "fr"). Requires locationCode; derived from the location when omitted.',
|
'Optional language code (e.g. "en", "fr"). Requires locationCode; derived from the location when omitted.',
|
||||||
),
|
),
|
||||||
|
organizationId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Organization id to create the project in. Required when the user belongs to more than one organization — omitting it returns the list; confirm the choice with the user before retrying.",
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
|
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
|
||||||
|
|
||||||
|
// Which organization gets the project. Pinned credentials (OAuth tokens,
|
||||||
|
// self-host, SAM) are bound to one org. User-scoped credentials (API keys)
|
||||||
|
// span organizations, so an ambiguous target is an error listing the options —
|
||||||
|
// the agent must confirm the choice with the user rather than guessing.
|
||||||
|
async function resolveTargetOrganization(
|
||||||
|
auth: Omit<ToolContext["auth"], "baseUrl">,
|
||||||
|
organizationId: string | undefined,
|
||||||
|
) {
|
||||||
|
if (auth.orgScope !== "user") {
|
||||||
|
if (organizationId && organizationId !== auth.organizationId) {
|
||||||
|
throw new AppError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"This connection is bound to a single organization — omit organizationId.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { organizationId: auth.organizationId, role: auth.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await AuthRepository.listMembershipsForUser(auth.userId);
|
||||||
|
if (organizationId) {
|
||||||
|
const membership = memberships.find(
|
||||||
|
(candidate) => candidate.organizationId === organizationId,
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"The user is not a member of that organization.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { organizationId, role: membership.role };
|
||||||
|
}
|
||||||
|
if (memberships.length === 1) {
|
||||||
|
const only = memberships[0];
|
||||||
|
return { organizationId: only.organizationId, role: only.role };
|
||||||
|
}
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
throw new AppError("FORBIDDEN");
|
||||||
|
}
|
||||||
|
const organizationList = memberships
|
||||||
|
.map(
|
||||||
|
(membership) =>
|
||||||
|
`- ${membership.organizationId} ${membership.organizationName}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
`The user belongs to ${memberships.length} organizations. Ask the user which organization this project should be created in, then retry with organizationId set:\n${organizationList}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const createProjectTool = {
|
export const createProjectTool = {
|
||||||
name: "create_project",
|
name: "create_project",
|
||||||
config: {
|
config: {
|
||||||
@ -65,10 +125,16 @@ export const createProjectTool = {
|
|||||||
},
|
},
|
||||||
handler: async (args: Args, context: ToolContext) => {
|
handler: async (args: Args, context: ToolContext) => {
|
||||||
const { baseUrl, ...auth } = context.auth;
|
const { baseUrl, ...auth } = context.auth;
|
||||||
|
const target = await resolveTargetOrganization(auth, args.organizationId);
|
||||||
|
// Same gate as the createProject server function — MCP and the dashboard
|
||||||
|
// must agree on who can create projects. The role is the caller's role
|
||||||
|
// in the TARGET organization, not the request-level one.
|
||||||
|
requireOrgPermission(target, { project: ["create"] });
|
||||||
// Reuse the app's create schema so the market pair rule (a languageCode
|
// Reuse the app's create schema so the market pair rule (a languageCode
|
||||||
// requires a locationCode) and domain normalization match the dashboard.
|
// requires a locationCode) and domain normalization match the dashboard.
|
||||||
// A rejection is bad caller input, not a fault: VALIDATION_ERROR keeps it
|
// A rejection is bad caller input, not a fault: VALIDATION_ERROR keeps it
|
||||||
// out of error reporting while still naming the bad field.
|
// out of error reporting while still naming the bad field. organizationId
|
||||||
|
// is stripped here — it was consumed above.
|
||||||
const parsedInput = createProjectSchema.safeParse(args);
|
const parsedInput = createProjectSchema.safeParse(args);
|
||||||
if (!parsedInput.success) {
|
if (!parsedInput.success) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@ -78,7 +144,7 @@ export const createProjectTool = {
|
|||||||
}
|
}
|
||||||
const input = parsedInput.data;
|
const input = parsedInput.data;
|
||||||
const project = await ProjectService.createProject(
|
const project = await ProjectService.createProject(
|
||||||
auth.organizationId,
|
target.organizationId,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
import { type ToolContext } from "@/server/mcp/context";
|
import { type ToolContext } from "@/server/mcp/context";
|
||||||
@ -5,12 +6,42 @@ import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
|||||||
import { buildDashboardUrl } from "@/server/mcp/urls";
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// The org(s) whose projects the caller can see. Pinned credentials (OAuth
|
||||||
|
// tokens, self-host) see the bound org; user-scoped credentials (API keys)
|
||||||
|
// see every organization the user belongs to, labeled so the agent can tell
|
||||||
|
// same-named projects apart.
|
||||||
|
async function listVisibleProjects(auth: Omit<ToolContext["auth"], "baseUrl">) {
|
||||||
|
if (auth.orgScope !== "user") {
|
||||||
|
const projects = await ProjectService.listProjects(auth.organizationId);
|
||||||
|
return projects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
organization: undefined,
|
||||||
|
organizationId: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await AuthRepository.listMembershipsForUser(auth.userId);
|
||||||
|
const byOrg = await Promise.all(
|
||||||
|
memberships.map(async (membership) => {
|
||||||
|
const projects = await ProjectService.listProjects(
|
||||||
|
membership.organizationId,
|
||||||
|
);
|
||||||
|
return projects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
organization: membership.organizationName,
|
||||||
|
organizationId: membership.organizationId,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return byOrg.flat();
|
||||||
|
}
|
||||||
|
|
||||||
export const listProjectsTool = {
|
export const listProjectsTool = {
|
||||||
name: "list_projects",
|
name: "list_projects",
|
||||||
config: {
|
config: {
|
||||||
title: "List projects",
|
title: "List projects",
|
||||||
description:
|
description:
|
||||||
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args.",
|
"Lists the user's projects. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args. When the user belongs to several organizations, each project is labeled with its organization and organizationId (pass that to create_project).",
|
||||||
inputSchema: {} as Record<string, never>,
|
inputSchema: {} as Record<string, never>,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
projects: z.array(
|
projects: z.array(
|
||||||
@ -22,6 +53,8 @@ export const listProjectsTool = {
|
|||||||
locationCode: z.number(),
|
locationCode: z.number(),
|
||||||
languageCode: z.string(),
|
languageCode: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
|
organization: z.string().optional(),
|
||||||
|
organizationId: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
),
|
),
|
||||||
@ -35,13 +68,13 @@ export const listProjectsTool = {
|
|||||||
},
|
},
|
||||||
handler: async (_args: Record<string, never>, context: ToolContext) => {
|
handler: async (_args: Record<string, never>, context: ToolContext) => {
|
||||||
const { baseUrl, ...auth } = context.auth;
|
const { baseUrl, ...auth } = context.auth;
|
||||||
const projects = await ProjectService.listProjects(auth.organizationId);
|
const projects = await listVisibleProjects(auth);
|
||||||
const lines =
|
const lines =
|
||||||
projects.length === 0
|
projects.length === 0
|
||||||
? ["No projects yet. Create one in the dashboard."]
|
? ["No projects yet. Create one in the dashboard."]
|
||||||
: projects.map(
|
: projects.map(
|
||||||
(p) =>
|
(p) =>
|
||||||
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""} market:${p.locationCode}/${p.languageCode}`,
|
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}${p.organization ? ` organization:${p.organization} [${p.organizationId}]` : ""} market:${p.locationCode}/${p.languageCode}`,
|
||||||
);
|
);
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
|
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
|
||||||
@ -56,6 +89,8 @@ export const listProjectsTool = {
|
|||||||
locationCode: p.locationCode,
|
locationCode: p.locationCode,
|
||||||
languageCode: p.languageCode,
|
languageCode: p.languageCode,
|
||||||
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
|
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
|
||||||
|
...(p.organization ? { organization: p.organization } : {}),
|
||||||
|
...(p.organizationId ? { organizationId: p.organizationId } : {}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,12 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// project-auth imports the repository for user-scoped (API key) credentials;
|
||||||
|
// unused here (pinned context) but keeps the db out of the module graph.
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: { getMembership: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
|
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
|
||||||
KeywordResearchService: {
|
KeywordResearchService: {
|
||||||
getSavedKeywords: mocks.getSavedKeywords,
|
getSavedKeywords: mocks.getSavedKeywords,
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export function makeToolContext(
|
|||||||
userId: "user_123",
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
userEmail: "alice@example.com",
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
role: "owner",
|
||||||
|
orgScope: "pinned",
|
||||||
clientId: "client_123",
|
clientId: "client_123",
|
||||||
scopes: ["mcp"],
|
scopes: ["mcp"],
|
||||||
baseUrl: "https://open-seo.test",
|
baseUrl: "https://open-seo.test",
|
||||||
|
|||||||
@ -9,6 +9,14 @@ vi.mock("@/lib/auth", () => ({
|
|||||||
getHostedBaseUrl: () => "https://open-seo.test",
|
getHostedBaseUrl: () => "https://open-seo.test",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The hosted transport re-checks membership per request; mocking the
|
||||||
|
// repository keeps this test off `cloudflare:workers`-backed db imports.
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: {
|
||||||
|
getMembership: vi.fn(async () => ({ role: "owner" })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
||||||
resolveCloudflareAccessContext: vi.fn(),
|
resolveCloudflareAccessContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -17,6 +17,14 @@ const selfHostedAuthMocks = vi.hoisted(() => ({
|
|||||||
createMcpHandler: vi.fn(),
|
createMcpHandler: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const authRepositoryMocks = vi.hoisted(() => ({
|
||||||
|
getMembership: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||||
|
AuthRepository: authRepositoryMocks,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
||||||
resolveCloudflareAccessContext:
|
resolveCloudflareAccessContext:
|
||||||
selfHostedAuthMocks.resolveCloudflareAccessContext,
|
selfHostedAuthMocks.resolveCloudflareAccessContext,
|
||||||
@ -201,6 +209,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
authRepositoryMocks.getMembership.mockResolvedValue({ role: "owner" });
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
|
it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
|
||||||
const props = hostedProps();
|
const props = hostedProps();
|
||||||
|
|
||||||
@ -223,9 +235,14 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
|||||||
legacy: "reject",
|
legacy: "reject",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith(
|
// The transport stamps the per-request role into the props it hands the
|
||||||
props,
|
// server; roles are never baked into tokens.
|
||||||
);
|
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
|
||||||
|
[MCP_AUTH_CONTEXT_PROP]: {
|
||||||
|
...props[MCP_AUTH_CONTEXT_PROP],
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes modern-era requests to the SDK handler", async () => {
|
it("routes modern-era requests to the SDK handler", async () => {
|
||||||
@ -288,9 +305,12 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith(
|
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
|
||||||
props,
|
[MCP_AUTH_CONTEXT_PROP]: {
|
||||||
);
|
...props[MCP_AUTH_CONTEXT_PROP],
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@ -335,6 +355,29 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a token whose user is no longer a member of the granted org", async () => {
|
||||||
|
// Tokens pin organizationId at consent time; once the membership is gone
|
||||||
|
// the token must stop working and push the client back through OAuth.
|
||||||
|
authRepositoryMocks.getMembership.mockResolvedValue(null);
|
||||||
|
const props = createWorkersOAuthMcpProps({
|
||||||
|
userId: "user-1",
|
||||||
|
userEmail: "user@example.com",
|
||||||
|
organizationId: "org-1",
|
||||||
|
baseUrl: "https://open-seo.test",
|
||||||
|
clientId: "client-1",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await handleAuthenticatedOpenSeoMcpRequest(
|
||||||
|
createMcpRequest(),
|
||||||
|
props,
|
||||||
|
{},
|
||||||
|
{ ...ctx, props },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an OAuth client without the MCP scope", async () => {
|
it("rejects an OAuth client without the MCP scope", async () => {
|
||||||
const props = hostedProps(["offline_access"]);
|
const props = hostedProps(["offline_access"]);
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/server/mcp/context";
|
} from "@/server/mcp/context";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { createOpenSeoMcpServer } from "@/server/mcp/server";
|
import { createOpenSeoMcpServer } from "@/server/mcp/server";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
|
||||||
// Mirrors the agents SDK's DEFAULT_CORS_OPTIONS so legacy responses carry the
|
// Mirrors the agents SDK's DEFAULT_CORS_OPTIONS so legacy responses carry the
|
||||||
// same CORS surface as the modern handler's.
|
// same CORS surface as the modern handler's.
|
||||||
@ -176,7 +177,32 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
|
|||||||
return withMcpCors(new Response("Invalid Origin", { status: 403 }));
|
return withMcpCors(new Response("Invalid Origin", { status: 403 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return createRequestHandler(result.data, [
|
// Tokens snapshot organizationId at consent and refresh copies it verbatim,
|
||||||
|
// so the grant can outlive the membership (member removed, org changed).
|
||||||
|
// Re-check the member row per request; 401 invalid_token pushes compliant
|
||||||
|
// clients back through OAuth, where consent stamps their current org.
|
||||||
|
const authContext = result.data[MCP_AUTH_CONTEXT_PROP];
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
authContext.userId,
|
||||||
|
authContext.organizationId,
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Organization access revoked", {
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler would fall back to the provider-populated ctx.props on its
|
||||||
|
// own; passing authContext explicitly hands it the schema-validated copy
|
||||||
|
// (with the per-request role stamped in — roles are never baked into
|
||||||
|
// tokens) and keeps this path symmetrical with self-hosted, which has no
|
||||||
|
// ctx.props.
|
||||||
|
const requestProps = createWorkersOAuthMcpProps({
|
||||||
|
...authContext,
|
||||||
|
role: membership.role,
|
||||||
|
});
|
||||||
|
return createRequestHandler(requestProps, [
|
||||||
hostedUrl.hostname,
|
hostedUrl.hostname,
|
||||||
SURFMIND_CHROME_EXTENSION_HOSTNAME,
|
SURFMIND_CHROME_EXTENSION_HOSTNAME,
|
||||||
])(request, env, ctx);
|
])(request, env, ctx);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { waitUntil } from "cloudflare:workers";
|
import { waitUntil } from "cloudflare:workers";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
@ -77,6 +78,9 @@ export const deleteAudit = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(deleteAuditSchema)
|
.validator(deleteAuditSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
// Deleting audits frees the org's free-plan capacity ceiling (a SUM over
|
||||||
|
// audit rows), so it gets the same destructive-action gate as archiving.
|
||||||
|
requireOrgPermission(context, { project: ["delete"] });
|
||||||
await AuditService.remove(data.auditId, context.projectId);
|
await AuditService.remove(data.auditId, context.projectId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
createSelfHostedGoogleAuthorizationUrl,
|
createSelfHostedGoogleAuthorizationUrl,
|
||||||
GA4_INTEGRATION,
|
GA4_INTEGRATION,
|
||||||
} from "@/server/features/google/selfHostedOAuth";
|
} from "@/server/features/google/selfHostedOAuth";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
@ -162,6 +163,7 @@ export const setGa4Property = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(setPropertySchema)
|
.validator(setPropertySchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
requireOrgPermission(context, { integration: ["manage"] });
|
||||||
const connection = await Ga4Service.setProperty({
|
const connection = await Ga4Service.setProperty({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
organizationId: context.organizationId,
|
organizationId: context.organizationId,
|
||||||
@ -188,6 +190,7 @@ export const disconnectGa4 = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(projectScopedSchema)
|
.validator(projectScopedSchema)
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
|
requireOrgPermission(context, { integration: ["manage"] });
|
||||||
await Ga4Service.disconnect({
|
await Ga4Service.disconnect({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
createSelfHostedGoogleAuthorizationUrl,
|
createSelfHostedGoogleAuthorizationUrl,
|
||||||
GSC_INTEGRATION,
|
GSC_INTEGRATION,
|
||||||
} from "@/server/features/google/selfHostedOAuth";
|
} from "@/server/features/google/selfHostedOAuth";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
@ -92,6 +93,7 @@ export const setGscSite = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(setSiteSchema)
|
.validator(setSiteSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
requireOrgPermission(context, { integration: ["manage"] });
|
||||||
const connection = await GscService.setSite({
|
const connection = await GscService.setSite({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
organizationId: context.organizationId,
|
organizationId: context.organizationId,
|
||||||
@ -114,6 +116,7 @@ export const disconnectGsc = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(projectScopedSchema)
|
.validator(projectScopedSchema)
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
|
requireOrgPermission(context, { integration: ["manage"] });
|
||||||
await GscService.disconnect({
|
await GscService.disconnect({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
|
|||||||
userEmail: z.string(),
|
userEmail: z.string(),
|
||||||
emailVerified: z.boolean(),
|
emailVerified: z.boolean(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
|
role: z.string(),
|
||||||
project: z.any().optional(),
|
project: z.any().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
156
src/serverFunctions/organization.ts
Normal file
156
src/serverFunctions/organization.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
|
||||||
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
||||||
|
import { consumeInvitationSendBudget } from "@/server/auth/invitation-send-limit";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { sendHostedInvitationEmail } from "@/server/email/loops";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
|
|
||||||
|
// The client's source of truth for "who am I in this organization": the active
|
||||||
|
// org, the caller's role from their member row (resolved server-side by
|
||||||
|
// ensure-user), and every organization they belong to (for the switcher). Used
|
||||||
|
// to gate billing/team UI; the server functions and better-auth endpoints
|
||||||
|
// re-enforce every permission regardless.
|
||||||
|
export const getOrganizationContext = createServerFn({ method: "GET" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const memberships = await AuthRepository.listMembershipsForUser(
|
||||||
|
context.userId,
|
||||||
|
);
|
||||||
|
const active = memberships.find(
|
||||||
|
(membership) => membership.organizationId === context.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
organizationName: active?.organizationName ?? "Organization",
|
||||||
|
role: context.role,
|
||||||
|
organizations: memberships,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Team data for the organization settings tab. Pending invitations are
|
||||||
|
// sensitive (invitee emails, inviter ids) and should only be visible to callers
|
||||||
|
// who can manage invitations; the server filters them here so the client cannot
|
||||||
|
// bypass the gate by calling the underlying endpoint directly.
|
||||||
|
export const getTeam = createServerFn({ method: "GET" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const fullOrganization = await getAuth().api.getFullOrganization({
|
||||||
|
headers: getRequest().headers,
|
||||||
|
query: { organizationId: context.organizationId },
|
||||||
|
});
|
||||||
|
if (!fullOrganization) {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canViewInvitations = hasOrgPermission(context.role, {
|
||||||
|
invitation: ["create"],
|
||||||
|
});
|
||||||
|
const now = Date.now();
|
||||||
|
const pendingInvitations = canViewInvitations
|
||||||
|
? (fullOrganization.invitations ?? []).filter(
|
||||||
|
(invitation) =>
|
||||||
|
invitation.status === "pending" &&
|
||||||
|
new Date(invitation.expiresAt).getTime() > now,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: fullOrganization.members ?? [],
|
||||||
|
pendingInvitations,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchOrganizationSchema = z.object({
|
||||||
|
organizationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch the active organization and persist the choice so the next sign-in
|
||||||
|
// lands in the same org (session hook reads user.lastActiveOrganizationId).
|
||||||
|
export const switchOrganization = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(switchOrganizationSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
context.userId,
|
||||||
|
data.organizationId,
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await getAuth().api.setActiveOrganization({
|
||||||
|
headers: getRequest().headers,
|
||||||
|
body: { organizationId: data.organizationId },
|
||||||
|
});
|
||||||
|
await AuthRepository.setLastActiveOrganization(
|
||||||
|
context.userId,
|
||||||
|
data.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { organizationId: data.organizationId };
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendInvitationSchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
|
// Invite (or re-invite) a teammate. better-auth creates the pending
|
||||||
|
// invitation — its server-side checks (inviter must be a member with invite
|
||||||
|
// permission, admin-only role lock, 20-pending cap) all still run — but the
|
||||||
|
// email is sent here rather than via the plugin's sendInvitationEmail
|
||||||
|
// callback: better-auth swallows throws from that callback, so a failed send
|
||||||
|
// would still read as "sent". Here it fails the call. resend: true re-mails
|
||||||
|
// an existing pending invitation with the same link and a refreshed expiry.
|
||||||
|
export const sendTeamInvitation = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(sendInvitationSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
// Defense in depth: fail-closed locally before touching the rate-limit
|
||||||
|
// budget or calling the plugin, so the budget cannot be exhausted by
|
||||||
|
// low-privilege callers.
|
||||||
|
requireOrgPermission(context, { invitation: ["create"] });
|
||||||
|
|
||||||
|
await consumeInvitationSendBudget(context.organizationId, data.email);
|
||||||
|
|
||||||
|
const invitation = await getAuth().api.createInvitation({
|
||||||
|
headers: getRequest().headers,
|
||||||
|
body: {
|
||||||
|
email: data.email,
|
||||||
|
role: "admin",
|
||||||
|
resend: true,
|
||||||
|
// Bind the invitation to the request's resolved org, not the session's
|
||||||
|
// active-organization hint, which can be stale after a switch.
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [inviter, memberships] = await Promise.all([
|
||||||
|
AuthRepository.getHostedUser(context.userId),
|
||||||
|
AuthRepository.listMembershipsForUser(context.userId),
|
||||||
|
]);
|
||||||
|
const organizationName =
|
||||||
|
memberships.find(
|
||||||
|
(membership) => membership.organizationId === context.organizationId,
|
||||||
|
)?.organizationName ?? "Organization";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendHostedInvitationEmail({
|
||||||
|
email: data.email,
|
||||||
|
inviteUrl: `${getHostedBaseUrl()}/accept-invitation/${invitation.id}`,
|
||||||
|
organizationName,
|
||||||
|
inviterName: inviter?.name?.trim() || context.userEmail,
|
||||||
|
inviterEmail: context.userEmail,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The invitation row exists and stays pending — surface the send
|
||||||
|
// failure so the inviter retries instead of assuming it landed.
|
||||||
|
console.error("Invitation email send failed:", error);
|
||||||
|
throw new AppError("UPSTREAM_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invitationId: invitation.id };
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import {
|
import {
|
||||||
requireAuthenticatedContext,
|
requireAuthenticatedContext,
|
||||||
@ -25,9 +26,10 @@ export const getProjects = createServerFn({ method: "POST" })
|
|||||||
export const createProject = createServerFn({ method: "POST" })
|
export const createProject = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.validator(createProjectSchema)
|
.validator(createProjectSchema)
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) => {
|
||||||
ProjectService.createProject(context.organizationId, data),
|
requireOrgPermission(context, { project: ["create"] });
|
||||||
);
|
return ProjectService.createProject(context.organizationId, data);
|
||||||
|
});
|
||||||
|
|
||||||
export const updateProject = createServerFn({ method: "POST" })
|
export const updateProject = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
@ -53,9 +55,10 @@ export const setProjectMarket = createServerFn({ method: "POST" })
|
|||||||
export const archiveProject = createServerFn({ method: "POST" })
|
export const archiveProject = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(archiveProjectSchema)
|
.validator(archiveProjectSchema)
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) => {
|
||||||
ProjectService.archiveProject(context.organizationId, data),
|
requireOrgPermission(context, { project: ["delete"] });
|
||||||
);
|
return ProjectService.archiveProject(context.organizationId, data);
|
||||||
|
});
|
||||||
|
|
||||||
export const getArchivedProjects = createServerFn({ method: "POST" })
|
export const getArchivedProjects = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
@ -66,9 +69,10 @@ export const getArchivedProjects = createServerFn({ method: "POST" })
|
|||||||
export const restoreProject = createServerFn({ method: "POST" })
|
export const restoreProject = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.validator(restoreProjectSchema)
|
.validator(restoreProjectSchema)
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) => {
|
||||||
ProjectService.restoreProject(context.organizationId, data),
|
requireOrgPermission(context, { project: ["delete"] });
|
||||||
);
|
return ProjectService.restoreProject(context.organizationId, data);
|
||||||
|
});
|
||||||
|
|
||||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user