Phase 3: team-mode owner setup, user management, shared workspace
Makes AUTH_MODE=team usable end to end. - resolveTeamContext (middleware/ensure-user/team.ts): a session resolves to a membership in the single shared workspace. No per-user fallback org — a signed-in user with no membership is treated as signed out, so the owner can actually remove people. - teamProvisioning.ts: one path that writes user + credential account + member together (hashPassword from better-auth/crypto). Shared by both entry points. - /api/team-setup (raw route, outside auth middleware): GET reports whether an owner is needed; POST creates the first owner + the shared org, then self-disables once any user exists. - /setup route + sign-in redirect: first run sends you to create the owner. - teamUsers server functions (owner/admin-gated): list / create (with temp password) / reset password / remove. Removal drops membership + sessions, keeps the user row for historical attribution. - Settings gains a "Users" tab in team mode (TeamUsers.tsx). - docs/SELF_HOSTING_TEAM_MODE.md: activation runbook (.env, build, first owner). No DB migration — all rows are existing better-auth tables. tsc / oxlint / knip clean. New teamProvisioning.test.ts (4 cases) passes; suite otherwise unchanged (pre-existing samSkills.test.ts CRLF failure only). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c47b032f1a
commit
acc35c055c
79
docs/SELF_HOSTING_TEAM_MODE.md
Normal file
79
docs/SELF_HOSTING_TEAM_MODE.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Self-hosting with team logins (`AUTH_MODE=team`)
|
||||||
|
|
||||||
|
`team` mode turns the app into a single shared workspace with individual
|
||||||
|
email/password logins. There is no billing, no Google login, no email
|
||||||
|
verification, and no self-serve signup — the owner provisions every account.
|
||||||
|
|
||||||
|
Use it when you want your team on one internal instance and need to see who did
|
||||||
|
what. For a public multi-tenant product, use `hosted` instead.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- Email/password sign-in for each teammate.
|
||||||
|
- One shared workspace (organization). Everyone works in the same projects.
|
||||||
|
- Roles: **owner** (you), **admin** (full access except billing/owner actions),
|
||||||
|
**member** (research + view).
|
||||||
|
- Two ways to add people:
|
||||||
|
- **Settings → Users** — create an account with a temporary password directly.
|
||||||
|
- **Settings → Organization** — send an email invite link (needs no email
|
||||||
|
provider config to create the invite; the link is shown in the UI).
|
||||||
|
|
||||||
|
## 1. Set environment variables
|
||||||
|
|
||||||
|
Edit `.env` in the deployment directory (for the pm2 setup that is
|
||||||
|
`/home/dev/DOCKER/OPEN-SEO/open-seo/.env`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AUTH_MODE=team
|
||||||
|
BETTER_AUTH_URL=https://seo.thedomainnest.com # the exact public origin, https
|
||||||
|
BETTER_AUTH_SECRET=<64 hex chars> # openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTH_MODE` is compiled into the browser bundle, so it must be present when
|
||||||
|
`vite build` runs — not only at runtime. Vite reads this same `.env` at build
|
||||||
|
time, so one edit covers both as long as the build runs in this directory.
|
||||||
|
|
||||||
|
Keep the existing `DATABASE_URL`, `DATAFORSEO_API_KEY`, etc.
|
||||||
|
|
||||||
|
## 2. Rebuild and restart
|
||||||
|
|
||||||
|
If your pipeline builds on push, commit/push and let it run. To do it by hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/dev/DOCKER/OPEN-SEO/open-seo
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
pm2 restart OPEN-SEO --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm config: `curl -s http://127.0.0.1:3001/api/health | jq .checks.auth`
|
||||||
|
should report `team`.
|
||||||
|
|
||||||
|
## 3. Create the owner account
|
||||||
|
|
||||||
|
Open the site. With `team` mode on and no users yet, `/sign-in` redirects to
|
||||||
|
`/setup`. Fill in name, email, password → **Create workspace**. You are signed
|
||||||
|
in as the owner.
|
||||||
|
|
||||||
|
`/setup` disables itself the moment the owner exists. If you ever need to reset,
|
||||||
|
delete all rows from `user` (and `member`, `account`, `session`) in Postgres and
|
||||||
|
reload.
|
||||||
|
|
||||||
|
## 4. Add your team
|
||||||
|
|
||||||
|
**Settings → Users → Add user.** Enter their email, name, a temporary password,
|
||||||
|
and a role. Share the password out-of-band; they change it after signing in
|
||||||
|
(Settings → Personal, once that lands) or you reset it from the same screen.
|
||||||
|
|
||||||
|
Removing a user drops their membership and signs them out everywhere. Their
|
||||||
|
`user` row is kept so past activity still attributes correctly; re-adding them
|
||||||
|
issues a fresh password.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Password reset by email is not available in `team` mode. The owner/admins
|
||||||
|
reset passwords from Settings → Users.
|
||||||
|
- The MCP server and its OAuth flow are hosted-only for now; `team` deployments
|
||||||
|
serve the app UI only.
|
||||||
|
- Rolling back: set `AUTH_MODE=local_noauth`, rebuild, restart. Existing users
|
||||||
|
and data stay in the database, just unused.
|
||||||
45
src/client/features/auth/teamSetup.ts
Normal file
45
src/client/features/auth/teamSetup.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const statusSchema = z.object({
|
||||||
|
mode: z.enum(["team", "other"]),
|
||||||
|
needsOwner: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TeamSetupStatus = z.infer<typeof statusSchema>;
|
||||||
|
|
||||||
|
const OTHER: TeamSetupStatus = { mode: "other", needsOwner: false };
|
||||||
|
|
||||||
|
const bootstrapResultSchema = z.object({
|
||||||
|
ok: z.boolean().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Talks to the raw /api/team-setup route (which lives outside the auth
|
||||||
|
// middleware, so it works before any session exists).
|
||||||
|
export async function fetchTeamSetupStatus(): Promise<TeamSetupStatus> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/team-setup", { method: "GET" });
|
||||||
|
if (!response.ok) return OTHER;
|
||||||
|
const raw: unknown = await response.json();
|
||||||
|
return statusSchema.catch(OTHER).parse(raw);
|
||||||
|
} catch {
|
||||||
|
return OTHER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapTeamOwner(input: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const response = await fetch("/api/team-setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const raw: unknown = await response.json().catch(() => ({}));
|
||||||
|
const data = bootstrapResultSchema.catch({}).parse(raw);
|
||||||
|
if (!response.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || "We couldn't complete setup. Try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
345
src/client/features/team/TeamUsers.tsx
Normal file
345
src/client/features/team/TeamUsers.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
createTeamUser,
|
||||||
|
listTeamUsers,
|
||||||
|
removeTeamUser,
|
||||||
|
resetTeamUserPassword,
|
||||||
|
} from "@/serverFunctions/teamUsers";
|
||||||
|
|
||||||
|
type TeamUser = Awaited<ReturnType<typeof listTeamUsers>>[number];
|
||||||
|
|
||||||
|
const TEAM_USERS_KEY = ["team-users"] as const;
|
||||||
|
|
||||||
|
function errorText(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `team` mode account management: the owner/admins create sign-in credentials
|
||||||
|
// directly here. Invitations (email links) live on the Organization tab.
|
||||||
|
export function TeamUsers() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const [resetFor, setResetFor] = useState<TeamUser | null>(null);
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: TEAM_USERS_KEY,
|
||||||
|
queryFn: () => listTeamUsers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: TEAM_USERS_KEY });
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => removeTeamUser({ data: { userId } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User removed");
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(errorText(error, "We couldn't remove that user.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usersQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
We couldn’t load the user list.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-soft btn-sm"
|
||||||
|
onClick={() => void usersQuery.refetch()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = usersQuery.data ?? [];
|
||||||
|
|
||||||
|
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">Users</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setIsAddOpen(true)}
|
||||||
|
>
|
||||||
|
Add user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Create a sign-in for each teammate. Share the email and temporary
|
||||||
|
password; they can change it after signing in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{usersQuery.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>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Added</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.userId}>
|
||||||
|
<td>
|
||||||
|
<div className="font-medium">{user.name}</div>
|
||||||
|
<div className="text-xs text-base-content/60">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="capitalize">{user.role}</td>
|
||||||
|
<td className="text-xs text-base-content/60">
|
||||||
|
{new Date(user.joinedAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
{user.isOwner ? null : (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() => setResetFor(user)}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
{user.isSelf ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Remove ${user.email} from the workspace?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeMutation.mutate(user.userId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAddOpen ? (
|
||||||
|
<AddUserModal
|
||||||
|
onClose={() => setIsAddOpen(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setIsAddOpen(false);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{resetFor ? (
|
||||||
|
<ResetPasswordModal
|
||||||
|
user={resetFor}
|
||||||
|
onClose={() => setResetFor(null)}
|
||||||
|
onDone={() => setResetFor(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<"admin" | "member">("member");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => createTeamUser({ data: { email, name, password, role } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("User created");
|
||||||
|
onCreated();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(errorText(error, "We couldn't create that user.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell title="Add user" onClose={onClose}>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Temporary password (min 8 characters)"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={role}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRole(event.target.value === "admin" ? "admin" : "member")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="member">Member — research + view</option>
|
||||||
|
<option value="admin">Admin — full access except billing</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Creating…" : "Create user"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordModal({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
user: TeamUser;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
resetTeamUserPassword({ data: { userId: user.userId, password } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Password reset for ${user.email}`);
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(errorText(error, "We couldn't reset that password.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell title={`Reset password — ${user.email}`} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Sets a new password and signs the user out of all devices.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="New password (min 8 characters)"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Saving…" : "Reset password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalShell({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-box border border-base-300 bg-base-100 p-5 shadow-xl">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-circle"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -47,6 +47,10 @@ export function isTeamAuthMode(value: string | null | undefined) {
|
|||||||
return getAuthMode(value) === "team";
|
return getAuthMode(value) === "team";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTeamClientAuthMode() {
|
||||||
|
return isTeamAuthMode(import.meta.env.AUTH_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
// "Is there a Better Auth login session?" — true for both the paid hosted SaaS
|
// "Is there a Better Auth login session?" — true for both the paid hosted SaaS
|
||||||
// and the internal `team` mode. Use this (not isHostedAuthMode) wherever the
|
// and the internal `team` mode. Use this (not isHostedAuthMode) wherever the
|
||||||
// question is "does this request carry a real user session?" rather than "is
|
// question is "does this request carry a real user session?" rather than "is
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode";
|
import { getAuthMode } from "@/lib/auth-mode";
|
||||||
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
|
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
|
||||||
import { resolveLocalNoAuthContext } from "./delegated";
|
import { resolveLocalNoAuthContext } from "./delegated";
|
||||||
import { resolveHostedContext } from "./hosted";
|
import { resolveHostedContext } from "./hosted";
|
||||||
|
import { resolveTeamContext } from "./team";
|
||||||
import type { EnsuredUserContext } from "./types";
|
import type { EnsuredUserContext } from "./types";
|
||||||
|
|
||||||
// Resolves the authenticated user for a request's headers across every auth
|
// Resolves the authenticated user for a request's headers across every auth
|
||||||
@ -15,10 +16,10 @@ export async function resolveUserContextFromHeaders(
|
|||||||
if (authMode === "local_noauth") {
|
if (authMode === "local_noauth") {
|
||||||
return resolveLocalNoAuthContext();
|
return resolveLocalNoAuthContext();
|
||||||
}
|
}
|
||||||
if (isSessionAuthMode(authMode)) {
|
if (authMode === "team") {
|
||||||
// `hosted` and `team` both resolve a Better Auth session; they differ only
|
return resolveTeamContext(headers);
|
||||||
// in config requirements (checked in resolveHostedContext) and which
|
}
|
||||||
// SaaS-only features are wired around them.
|
if (authMode === "hosted") {
|
||||||
return resolveHostedContext(headers);
|
return resolveHostedContext(headers);
|
||||||
}
|
}
|
||||||
return resolveCloudflareAccessContext(headers);
|
return resolveCloudflareAccessContext(headers);
|
||||||
|
|||||||
51
src/middleware/ensure-user/team.ts
Normal file
51
src/middleware/ensure-user/team.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import type { EnsuredUserContext } from "./types";
|
||||||
|
|
||||||
|
// `team` mode: a Better Auth session resolves to a membership in the single
|
||||||
|
// shared workspace. Unlike hosted, there is no per-user fallback organization —
|
||||||
|
// a signed-in user with no membership was removed by the owner, so they are
|
||||||
|
// treated as signed out rather than handed a fresh personal workspace.
|
||||||
|
export async function resolveTeamContext(
|
||||||
|
headers: Headers,
|
||||||
|
): Promise<EnsuredUserContext> {
|
||||||
|
if (!hasSessionAuthConfig()) {
|
||||||
|
throw new AppError(
|
||||||
|
"AUTH_CONFIG_MISSING",
|
||||||
|
"team mode needs BETTER_AUTH_URL and BETTER_AUTH_SECRET (32+ characters) on the deployment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getAuth().api.getSession({ headers });
|
||||||
|
|
||||||
|
if (!session?.user?.id || !session.user.email) {
|
||||||
|
throw new AppError("UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = await AuthRepository.findFirstOrganizationIdForUser(
|
||||||
|
session.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
throw new AppError("UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
session.user.id,
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppError("UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: session.user.id,
|
||||||
|
userEmail: session.user.email,
|
||||||
|
// `team` mode has no email-verification step.
|
||||||
|
emailVerified: true,
|
||||||
|
organizationId,
|
||||||
|
role: membership.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -18,12 +18,14 @@ import { Route as AuthRouteImport } from './routes/_auth'
|
|||||||
import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
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 ApiTeamSetupRouteImport } from './routes/api/team-setup'
|
||||||
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 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 AuthSetupRouteImport } from './routes/_auth.setup'
|
||||||
import { Route as AppTeamRouteImport } from './routes/_app/team'
|
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'
|
||||||
@ -36,6 +38,7 @@ import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/ind
|
|||||||
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 AppSettingsUsersRouteImport } from './routes/_app/settings/users'
|
||||||
import { Route as AppSettingsOrganizationRouteImport } from './routes/_app/settings/organization'
|
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'
|
||||||
@ -103,6 +106,11 @@ const AppIndexRoute = AppIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTeamSetupRoute = ApiTeamSetupRouteImport.update({
|
||||||
|
id: '/api/team-setup',
|
||||||
|
path: '/api/team-setup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiHealthRoute = ApiHealthRouteImport.update({
|
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||||
id: '/api/health',
|
id: '/api/health',
|
||||||
path: '/api/health',
|
path: '/api/health',
|
||||||
@ -134,6 +142,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({
|
|||||||
path: '/sign-in',
|
path: '/sign-in',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSetupRoute = AuthSetupRouteImport.update({
|
||||||
|
id: '/setup',
|
||||||
|
path: '/setup',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AppTeamRoute = AppTeamRouteImport.update({
|
const AppTeamRoute = AppTeamRouteImport.update({
|
||||||
id: '/team',
|
id: '/team',
|
||||||
path: '/team',
|
path: '/team',
|
||||||
@ -197,6 +210,11 @@ const AuthenticatedOnboardingChatRoute =
|
|||||||
path: '/onboarding/chat',
|
path: '/onboarding/chat',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppSettingsUsersRoute = AppSettingsUsersRouteImport.update({
|
||||||
|
id: '/users',
|
||||||
|
path: '/users',
|
||||||
|
getParentRoute: () => AppSettingsRoute,
|
||||||
|
} as any)
|
||||||
const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
|
const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
|
||||||
id: '/organization',
|
id: '/organization',
|
||||||
path: '/organization',
|
path: '/organization',
|
||||||
@ -350,16 +368,19 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof AppSettingsRouteWithChildren
|
'/settings': typeof AppSettingsRouteWithChildren
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/team': typeof AppTeamRoute
|
'/team': typeof AppTeamRoute
|
||||||
|
'/setup': typeof AuthSetupRoute
|
||||||
'/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
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/api/team-setup': typeof ApiTeamSetupRoute
|
||||||
'/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
|
'/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
|
'/settings/users': typeof AppSettingsUsersRoute
|
||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
@ -399,15 +420,18 @@ export interface FileRoutesByTo {
|
|||||||
'/projects': typeof AppProjectsRoute
|
'/projects': typeof AppProjectsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/team': typeof AppTeamRoute
|
'/team': typeof AppTeamRoute
|
||||||
|
'/setup': typeof AuthSetupRoute
|
||||||
'/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
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/api/team-setup': typeof ApiTeamSetupRoute
|
||||||
'/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
|
'/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
|
'/settings/users': typeof AppSettingsUsersRoute
|
||||||
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
@ -449,17 +473,20 @@ export interface FileRoutesById {
|
|||||||
'/_app/settings': typeof AppSettingsRouteWithChildren
|
'/_app/settings': typeof AppSettingsRouteWithChildren
|
||||||
'/_app/support': typeof AppSupportRoute
|
'/_app/support': typeof AppSupportRoute
|
||||||
'/_app/team': typeof AppTeamRoute
|
'/_app/team': typeof AppTeamRoute
|
||||||
|
'/_auth/setup': typeof AuthSetupRoute
|
||||||
'/_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
|
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
|
'/api/team-setup': typeof ApiTeamSetupRoute
|
||||||
'/_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
|
'/_app/settings/organization': typeof AppSettingsOrganizationRoute
|
||||||
|
'/_app/settings/users': typeof AppSettingsUsersRoute
|
||||||
'/_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
|
||||||
@ -502,16 +529,19 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/team'
|
| '/team'
|
||||||
|
| '/setup'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
| '/accept-invitation/$id'
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
|
| '/api/team-setup'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/help/openrouter-api-key'
|
| '/help/openrouter-api-key'
|
||||||
| '/settings/organization'
|
| '/settings/organization'
|
||||||
|
| '/settings/users'
|
||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
@ -551,15 +581,18 @@ export interface FileRouteTypes {
|
|||||||
| '/projects'
|
| '/projects'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/team'
|
| '/team'
|
||||||
|
| '/setup'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
| '/accept-invitation/$id'
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
|
| '/api/team-setup'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/help/openrouter-api-key'
|
| '/help/openrouter-api-key'
|
||||||
| '/settings/organization'
|
| '/settings/organization'
|
||||||
|
| '/settings/users'
|
||||||
| '/onboarding/chat'
|
| '/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
@ -600,17 +633,20 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/settings'
|
| '/_app/settings'
|
||||||
| '/_app/support'
|
| '/_app/support'
|
||||||
| '/_app/team'
|
| '/_app/team'
|
||||||
|
| '/_auth/setup'
|
||||||
| '/_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'
|
| '/accept-invitation/$id'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
|
| '/api/team-setup'
|
||||||
| '/_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'
|
| '/_app/settings/organization'
|
||||||
|
| '/_app/settings/users'
|
||||||
| '/_authenticated/onboarding/chat'
|
| '/_authenticated/onboarding/chat'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
@ -651,6 +687,7 @@ export interface RootRouteChildren {
|
|||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute
|
AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
|
ApiTeamSetupRoute: typeof ApiTeamSetupRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
|
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
|
||||||
@ -722,6 +759,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppIndexRouteImport
|
preLoaderRoute: typeof AppIndexRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/api/team-setup': {
|
||||||
|
id: '/api/team-setup'
|
||||||
|
path: '/api/team-setup'
|
||||||
|
fullPath: '/api/team-setup'
|
||||||
|
preLoaderRoute: typeof ApiTeamSetupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/health': {
|
'/api/health': {
|
||||||
id: '/api/health'
|
id: '/api/health'
|
||||||
path: '/api/health'
|
path: '/api/health'
|
||||||
@ -764,6 +808,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSignInRouteImport
|
preLoaderRoute: typeof AuthSignInRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/setup': {
|
||||||
|
id: '/_auth/setup'
|
||||||
|
path: '/setup'
|
||||||
|
fullPath: '/setup'
|
||||||
|
preLoaderRoute: typeof AuthSetupRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_app/team': {
|
'/_app/team': {
|
||||||
id: '/_app/team'
|
id: '/_app/team'
|
||||||
path: '/team'
|
path: '/team'
|
||||||
@ -848,6 +899,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
|
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_app/settings/users': {
|
||||||
|
id: '/_app/settings/users'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/settings/users'
|
||||||
|
preLoaderRoute: typeof AppSettingsUsersRouteImport
|
||||||
|
parentRoute: typeof AppSettingsRoute
|
||||||
|
}
|
||||||
'/_app/settings/organization': {
|
'/_app/settings/organization': {
|
||||||
id: '/_app/settings/organization'
|
id: '/_app/settings/organization'
|
||||||
path: '/organization'
|
path: '/organization'
|
||||||
@ -1028,11 +1086,13 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AppSettingsRouteChildren {
|
interface AppSettingsRouteChildren {
|
||||||
AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute
|
AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute
|
||||||
|
AppSettingsUsersRoute: typeof AppSettingsUsersRoute
|
||||||
AppSettingsIndexRoute: typeof AppSettingsIndexRoute
|
AppSettingsIndexRoute: typeof AppSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppSettingsRouteChildren: AppSettingsRouteChildren = {
|
const AppSettingsRouteChildren: AppSettingsRouteChildren = {
|
||||||
AppSettingsOrganizationRoute: AppSettingsOrganizationRoute,
|
AppSettingsOrganizationRoute: AppSettingsOrganizationRoute,
|
||||||
|
AppSettingsUsersRoute: AppSettingsUsersRoute,
|
||||||
AppSettingsIndexRoute: AppSettingsIndexRoute,
|
AppSettingsIndexRoute: AppSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1174,11 +1234,13 @@ const ProjectRouteRouteWithChildren = ProjectRouteRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
|
AuthSetupRoute: typeof AuthSetupRoute
|
||||||
AuthSignInRoute: typeof AuthSignInRoute
|
AuthSignInRoute: typeof AuthSignInRoute
|
||||||
AuthSignUpRoute: typeof AuthSignUpRoute
|
AuthSignUpRoute: typeof AuthSignUpRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
|
AuthSetupRoute: AuthSetupRoute,
|
||||||
AuthSignInRoute: AuthSignInRoute,
|
AuthSignInRoute: AuthSignInRoute,
|
||||||
AuthSignUpRoute: AuthSignUpRoute,
|
AuthSignUpRoute: AuthSignUpRoute,
|
||||||
}
|
}
|
||||||
@ -1216,6 +1278,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
||||||
AcceptInvitationIdRoute: AcceptInvitationIdRoute,
|
AcceptInvitationIdRoute: AcceptInvitationIdRoute,
|
||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
|
ApiTeamSetupRoute: ApiTeamSetupRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
|
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
||||||
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
import { isSessionClientAuthMode, isTeamClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings")({
|
export const Route = createFileRoute("/_app/settings")({
|
||||||
component: SettingsLayout,
|
component: SettingsLayout,
|
||||||
@ -15,6 +15,10 @@ function SettingsLayout() {
|
|||||||
...(isSessionClientAuthMode()
|
...(isSessionClientAuthMode()
|
||||||
? [{ to: "/settings/organization" as const, label: "Organization" }]
|
? [{ to: "/settings/organization" as const, label: "Organization" }]
|
||||||
: []),
|
: []),
|
||||||
|
// `team` mode adds direct account provisioning alongside invitations.
|
||||||
|
...(isTeamClientAuthMode()
|
||||||
|
? [{ to: "/settings/users" as const, label: "Users" }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
13
src/routes/_app/settings/users.tsx
Normal file
13
src/routes/_app/settings/users.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
|
import { TeamUsers } from "@/client/features/team/TeamUsers";
|
||||||
|
import { isTeamClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_app/settings/users")({
|
||||||
|
// Direct account provisioning only exists in `team` mode.
|
||||||
|
beforeLoad: () => {
|
||||||
|
if (!isTeamClientAuthMode()) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: TeamUsers,
|
||||||
|
});
|
||||||
105
src/routes/_auth.setup.tsx
Normal file
105
src/routes/_auth.setup.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AuthPageCard } from "@/client/features/auth/AuthPage";
|
||||||
|
import {
|
||||||
|
bootstrapTeamOwner,
|
||||||
|
fetchTeamSetupStatus,
|
||||||
|
} from "@/client/features/auth/teamSetup";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
// First-run screen for `team` mode: creates the single owner account. It
|
||||||
|
// redirects to /sign-in the moment an owner exists, so it can't be used to add
|
||||||
|
// more users.
|
||||||
|
export const Route = createFileRoute("/_auth/setup")({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const status = await fetchTeamSetupStatus();
|
||||||
|
if (!status.needsOwner) {
|
||||||
|
throw redirect({ to: "/sign-in", search: {} });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: SetupPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SetupPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await bootstrapTeamOwner({ name, email, password });
|
||||||
|
const result = await authClient.signIn.email({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
callbackURL: "/",
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
// Account created but auto sign-in failed — send them to sign in
|
||||||
|
// manually rather than leaving them stuck here.
|
||||||
|
void navigate({ to: "/sign-in", search: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Full reload so every query and the router start from the new session.
|
||||||
|
window.location.assign("/");
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(
|
||||||
|
submitError instanceof Error
|
||||||
|
? submitError.message
|
||||||
|
: "We couldn't complete setup.",
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageCard
|
||||||
|
title="Create the owner account"
|
||||||
|
helperText="This is the first and only owner of the workspace. Add teammates afterwards from Settings → Users."
|
||||||
|
>
|
||||||
|
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Password (min 8 characters)"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Setting up…" : "Create workspace"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { fetchTeamSetupStatus } from "@/client/features/auth/teamSetup";
|
||||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
@ -31,6 +32,20 @@ function SignInPage() {
|
|||||||
const authCallbackURL = redirectTo;
|
const authCallbackURL = redirectTo;
|
||||||
// `team` mode has no Google button, so go straight to the email/password form.
|
// `team` mode has no Google button, so go straight to the email/password form.
|
||||||
const [showEmailForm, setShowEmailForm] = useState(!isHostedMode);
|
const [showEmailForm, setShowEmailForm] = useState(!isHostedMode);
|
||||||
|
|
||||||
|
// `team` mode with no owner yet: send them to the one-time setup screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionMode) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchTeamSetupStatus().then((status) => {
|
||||||
|
if (!cancelled && status.needsOwner) {
|
||||||
|
void navigate({ to: "/setup", search: {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isSessionMode, navigate]);
|
||||||
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
||||||
const [socialError, setSocialError] = useState<string | null>(null);
|
const [socialError, setSocialError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
102
src/routes/api/team-setup.ts
Normal file
102
src/routes/api/team-setup.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isTeamAuthMode } from "@/lib/auth-mode";
|
||||||
|
import {
|
||||||
|
HOSTED_PASSWORD_MAX_LENGTH,
|
||||||
|
HOSTED_PASSWORD_MIN_LENGTH,
|
||||||
|
} from "@/lib/auth-options";
|
||||||
|
import {
|
||||||
|
countUsers,
|
||||||
|
createSharedOrganization,
|
||||||
|
getSharedOrganizationId,
|
||||||
|
provisionTeamMember,
|
||||||
|
} from "@/server/features/team/teamProvisioning";
|
||||||
|
|
||||||
|
// Unauthenticated setup endpoint for `team` mode: it exists only to create the
|
||||||
|
// very first (owner) account, and disables itself the moment a user exists. All
|
||||||
|
// other user management goes through the owner-gated team-users server
|
||||||
|
// functions. It sits outside ensureUserMiddleware (raw API route) because there
|
||||||
|
// is no session yet when the owner is created.
|
||||||
|
|
||||||
|
async function handleStatus(): Promise<Response> {
|
||||||
|
if (!isTeamAuthMode(env.AUTH_MODE)) {
|
||||||
|
return Response.json({ mode: "other", needsOwner: false });
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
mode: "team",
|
||||||
|
needsOwner: (await countUsers()) === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapSchema = z.object({
|
||||||
|
email: z.string().trim().email(),
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(HOSTED_PASSWORD_MIN_LENGTH)
|
||||||
|
.max(HOSTED_PASSWORD_MAX_LENGTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleBootstrap(request: Request): Promise<Response> {
|
||||||
|
if (!isTeamAuthMode(env.AUTH_MODE)) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-disabling: once any user exists, the owner already ran this.
|
||||||
|
if ((await countUsers()) > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "This workspace already has an owner. Sign in instead." },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = bootstrapSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: `Enter a name, a valid email, and a password ${HOSTED_PASSWORD_MIN_LENGTH}-${HOSTED_PASSWORD_MAX_LENGTH} characters long.`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId =
|
||||||
|
(await getSharedOrganizationId()) ?? (await createSharedOrganization());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provisionTeamMember({
|
||||||
|
...parsed.data,
|
||||||
|
role: "owner",
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not create the owner account.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/team-setup")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: () => handleStatus(),
|
||||||
|
POST: ({ request }: { request: Request }) => handleBootstrap(request),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -137,6 +137,31 @@ async function listMembershipsForUser(userId: string) {
|
|||||||
.orderBy(asc(member.createdAt));
|
.orderBy(asc(member.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roster for the team-users admin screen: every member of one organization
|
||||||
|
// with the identity fields the UI shows.
|
||||||
|
async function listOrganizationMembers(organizationId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
userId: member.userId,
|
||||||
|
email: authUser.email,
|
||||||
|
name: authUser.name,
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.createdAt,
|
||||||
|
})
|
||||||
|
.from(member)
|
||||||
|
.innerJoin(authUser, eq(member.userId, authUser.id))
|
||||||
|
.where(eq(member.organizationId, organizationId))
|
||||||
|
.orderBy(asc(member.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMembership(userId: string, organizationId: string) {
|
||||||
|
await db
|
||||||
|
.delete(member)
|
||||||
|
.where(
|
||||||
|
and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Case-insensitive on purpose: better-auth lowercases the address when it
|
// Case-insensitive on purpose: better-auth lowercases the address when it
|
||||||
// mails an invite, but the stored row keeps whatever the inviter typed.
|
// mails an invite, but the stored row keeps whatever the inviter typed.
|
||||||
async function hasPendingInvitationForEmail(email: string) {
|
async function hasPendingInvitationForEmail(email: string) {
|
||||||
@ -181,6 +206,8 @@ export const AuthRepository = {
|
|||||||
findNewestMembershipForUser,
|
findNewestMembershipForUser,
|
||||||
getMembership,
|
getMembership,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
|
listOrganizationMembers,
|
||||||
|
removeMembership,
|
||||||
getLastActiveOrganizationId,
|
getLastActiveOrganizationId,
|
||||||
setLastActiveOrganization,
|
setLastActiveOrganization,
|
||||||
getHostedUser,
|
getHostedUser,
|
||||||
|
|||||||
145
src/server/features/team/teamProvisioning.test.ts
Normal file
145
src/server/features/team/teamProvisioning.test.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { createClient, type Client } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import type * as TeamProvisioningModule from "./teamProvisioning";
|
||||||
|
|
||||||
|
// Real in-memory SQLite: provisionTeamMember writes user + credential account +
|
||||||
|
// member in one shot, and the duplicate-email guard is the invariant that keeps
|
||||||
|
// the owner from silently creating a second unusable row.
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({ env: { DATABASE_PROVIDER: "d1" } }));
|
||||||
|
|
||||||
|
let client: Client;
|
||||||
|
let mod: typeof TeamProvisioningModule;
|
||||||
|
|
||||||
|
async function rows(sql: string) {
|
||||||
|
return (await client.execute(sql)).rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
client = createClient({ url: "file::memory:" });
|
||||||
|
const testDb = drizzle(client);
|
||||||
|
vi.doMock("@/db", () => ({ db: testDb }));
|
||||||
|
vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
|
||||||
|
vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
|
||||||
|
|
||||||
|
await client.executeMultiple(`
|
||||||
|
CREATE TABLE organization (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL, logo TEXT, created_at INTEGER NOT NULL, metadata TEXT);
|
||||||
|
CREATE TABLE "user" (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, email_verified INTEGER NOT NULL DEFAULT 0, image TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, analytics_opted_out INTEGER, last_active_organization_id TEXT);
|
||||||
|
CREATE TABLE account (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
||||||
|
CREATE TABLE member (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at INTEGER NOT NULL);
|
||||||
|
CREATE TABLE session (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, token TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL, active_organization_id TEXT);
|
||||||
|
`);
|
||||||
|
|
||||||
|
mod = await import("./teamProvisioning");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await client.executeMultiple(`
|
||||||
|
DELETE FROM member; DELETE FROM account; DELETE FROM "user"; DELETE FROM organization; DELETE FROM session;
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("teamProvisioning", () => {
|
||||||
|
it("counts users and finds the oldest organization as the shared workspace", async () => {
|
||||||
|
expect(await mod.countUsers()).toBe(0);
|
||||||
|
expect(await mod.getSharedOrganizationId()).toBeNull();
|
||||||
|
|
||||||
|
const orgId = await mod.createSharedOrganization("CrawlerX");
|
||||||
|
expect(await mod.getSharedOrganizationId()).toBe(orgId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes user, credential account, and membership together", async () => {
|
||||||
|
const orgId = await mod.createSharedOrganization();
|
||||||
|
const { userId } = await mod.provisionTeamMember({
|
||||||
|
email: "Owner@Example.com",
|
||||||
|
name: " Ada ",
|
||||||
|
password: "correct-horse",
|
||||||
|
role: "owner",
|
||||||
|
organizationId: orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userRow] = await rows(
|
||||||
|
`SELECT email, name, email_verified FROM "user"`,
|
||||||
|
);
|
||||||
|
expect(userRow.email).toBe("owner@example.com");
|
||||||
|
expect(userRow.name).toBe("Ada");
|
||||||
|
expect(userRow.email_verified).toBe(1);
|
||||||
|
|
||||||
|
const [accountRow] = await rows(
|
||||||
|
`SELECT provider_id, account_id, password FROM account`,
|
||||||
|
);
|
||||||
|
expect(accountRow.provider_id).toBe("credential");
|
||||||
|
expect(accountRow.account_id).toBe(userId);
|
||||||
|
expect(accountRow.password).toBeTruthy();
|
||||||
|
|
||||||
|
const [memberRow] = await rows(`SELECT role, organization_id FROM member`);
|
||||||
|
expect(memberRow.role).toBe("owner");
|
||||||
|
expect(memberRow.organization_id).toBe(orgId);
|
||||||
|
|
||||||
|
expect(await mod.countUsers()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate email case-insensitively", async () => {
|
||||||
|
const orgId = await mod.createSharedOrganization();
|
||||||
|
await mod.provisionTeamMember({
|
||||||
|
email: "dup@example.com",
|
||||||
|
name: "First",
|
||||||
|
password: "password-one",
|
||||||
|
role: "member",
|
||||||
|
organizationId: orgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mod.provisionTeamMember({
|
||||||
|
email: "DUP@example.com",
|
||||||
|
name: "Second",
|
||||||
|
password: "password-two",
|
||||||
|
role: "member",
|
||||||
|
organizationId: orgId,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/already exists/i);
|
||||||
|
|
||||||
|
expect(await mod.countUsers()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the credential password and revokes sessions", async () => {
|
||||||
|
const orgId = await mod.createSharedOrganization();
|
||||||
|
const { userId } = await mod.provisionTeamMember({
|
||||||
|
email: "member@example.com",
|
||||||
|
name: "Mem",
|
||||||
|
password: "old-password",
|
||||||
|
role: "member",
|
||||||
|
organizationId: orgId,
|
||||||
|
});
|
||||||
|
await client.execute(
|
||||||
|
`INSERT INTO session (id, expires_at, token, created_at, updated_at, user_id) VALUES ('s1', 0, 't1', 0, 0, '${userId}')`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [before] = await rows(
|
||||||
|
`SELECT password FROM account WHERE user_id = '${userId}'`,
|
||||||
|
);
|
||||||
|
await mod.setCredentialPassword(userId, "brand-new-password");
|
||||||
|
const [after] = await rows(
|
||||||
|
`SELECT password FROM account WHERE user_id = '${userId}'`,
|
||||||
|
);
|
||||||
|
expect(after.password).not.toBe(before.password);
|
||||||
|
|
||||||
|
await mod.revokeUserSessions(userId);
|
||||||
|
expect(
|
||||||
|
await rows(`SELECT id FROM session WHERE user_id = '${userId}'`),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/server/features/team/teamProvisioning.ts
Normal file
130
src/server/features/team/teamProvisioning.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { hashPassword } from "better-auth/crypto";
|
||||||
|
import { count, eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { account, member, organization, session, user } from "@/db/schema";
|
||||||
|
import { slugify, toHex } from "@/server/auth/org-slug";
|
||||||
|
|
||||||
|
// `team` mode runs one shared workspace. There is no self-serve signup: the
|
||||||
|
// first owner is created through /api/team-setup, and every teammate after that
|
||||||
|
// through the owner-gated team-users server functions. Both paths funnel through
|
||||||
|
// `provisionTeamMember` so the user + credential account + membership rows are
|
||||||
|
// always written together.
|
||||||
|
|
||||||
|
const TEAM_DEFAULT_ORG_NAME = "CrawlerX";
|
||||||
|
|
||||||
|
type TeamMemberRole = "owner" | "admin" | "member";
|
||||||
|
|
||||||
|
export async function countUsers(): Promise<number> {
|
||||||
|
const [row] = await db.select({ value: count() }).from(user);
|
||||||
|
return row?.value ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared workspace is simply the oldest organization row — `team` mode
|
||||||
|
// never creates more than one.
|
||||||
|
export async function getSharedOrganizationId(): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: organization.id })
|
||||||
|
.from(organization)
|
||||||
|
.orderBy(organization.createdAt)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSharedOrganization(
|
||||||
|
name: string = TEAM_DEFAULT_ORG_NAME,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await db.insert(organization).values({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug: `${slugify(name)}-${toHex(id).slice(0, 8)}`,
|
||||||
|
logo: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUserIdByEmail(email: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(sql`lower(${user.email})`, email.trim().toLowerCase()))
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProvisionInput = {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
role: TeamMemberRole;
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function provisionTeamMember(
|
||||||
|
input: ProvisionInput,
|
||||||
|
): Promise<{ userId: string; email: string }> {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (await findUserIdByEmail(email)) {
|
||||||
|
throw new Error("A user with that email already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const userId = randomUUID();
|
||||||
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
|
||||||
|
// No transaction wrapper: the provider-aware `db` must stay D1-compatible and
|
||||||
|
// these three inserts are only ever driven by a single admin action. A
|
||||||
|
// partial write (e.g. account insert fails) leaves an orphan user row with no
|
||||||
|
// way to sign in, which the owner can delete and recreate.
|
||||||
|
await db.insert(user).values({
|
||||||
|
id: userId,
|
||||||
|
name: input.name.trim() || email.split("@")[0] || "Teammate",
|
||||||
|
email,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
analyticsOptedOut: false,
|
||||||
|
lastActiveOrganizationId: input.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(account).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: "credential",
|
||||||
|
userId,
|
||||||
|
password: passwordHash,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(member).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId,
|
||||||
|
role: input.role,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCredentialPassword(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await db
|
||||||
|
.update(account)
|
||||||
|
.set({ password: passwordHash, updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
sql`${account.userId} = ${userId} and ${account.providerId} = 'credential'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserSessions(userId: string): Promise<void> {
|
||||||
|
await db.delete(session).where(eq(session.userId, userId));
|
||||||
|
}
|
||||||
151
src/serverFunctions/teamUsers.ts
Normal file
151
src/serverFunctions/teamUsers.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isTeamAuthMode } from "@/lib/auth-mode";
|
||||||
|
import {
|
||||||
|
HOSTED_PASSWORD_MAX_LENGTH,
|
||||||
|
HOSTED_PASSWORD_MIN_LENGTH,
|
||||||
|
} from "@/lib/auth-options";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { requireOrgPermission } from "@/server/auth/org-gate";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import {
|
||||||
|
provisionTeamMember,
|
||||||
|
revokeUserSessions,
|
||||||
|
setCredentialPassword,
|
||||||
|
} from "@/server/features/team/teamProvisioning";
|
||||||
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
|
|
||||||
|
// Owner/admin-only team management for `team` mode. These never run in the
|
||||||
|
// hosted SaaS (which manages members through invitations + billing) or the
|
||||||
|
// delegated modes (no member rows) — the guard makes that explicit rather than
|
||||||
|
// relying on the UI to hide the screen.
|
||||||
|
|
||||||
|
function requireTeamMode() {
|
||||||
|
if (!isTeamAuthMode(env.AUTH_MODE)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Team user management is not enabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(HOSTED_PASSWORD_MIN_LENGTH)
|
||||||
|
.max(HOSTED_PASSWORD_MAX_LENGTH);
|
||||||
|
|
||||||
|
const createTeamUserSchema = z.object({
|
||||||
|
email: z.string().trim().email(),
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
password: passwordSchema,
|
||||||
|
role: z.enum(["admin", "member"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeUserSchema = z.object({ userId: z.string().min(1) });
|
||||||
|
|
||||||
|
function roleList(role: string) {
|
||||||
|
return role.split(",").map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listTeamUsers = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
requireTeamMode();
|
||||||
|
requireOrgPermission(context, { member: ["create"] });
|
||||||
|
|
||||||
|
const members = await AuthRepository.listOrganizationMembers(
|
||||||
|
context.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return members.map((entry) => ({
|
||||||
|
userId: entry.userId,
|
||||||
|
email: entry.email,
|
||||||
|
name: entry.name,
|
||||||
|
role: entry.role,
|
||||||
|
joinedAt:
|
||||||
|
entry.joinedAt instanceof Date
|
||||||
|
? entry.joinedAt.toISOString()
|
||||||
|
: new Date(entry.joinedAt).toISOString(),
|
||||||
|
isSelf: entry.userId === context.userId,
|
||||||
|
isOwner: roleList(entry.role).includes("owner"),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createTeamUser = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(createTeamUserSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
requireTeamMode();
|
||||||
|
requireOrgPermission(context, { member: ["create"] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await provisionTeamMember({
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
password: data.password,
|
||||||
|
role: data.role,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
});
|
||||||
|
return { userId: created.userId, email: created.email };
|
||||||
|
} catch (error) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not create the account.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetTeamUserPassword = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(resetPasswordSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
requireTeamMode();
|
||||||
|
requireOrgPermission(context, { member: ["create"] });
|
||||||
|
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
data.userId,
|
||||||
|
context.organizationId,
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppError("NOT_FOUND", "That user is not in this workspace.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCredentialPassword(data.userId, data.password);
|
||||||
|
await revokeUserSessions(data.userId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeTeamUser = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.validator(removeUserSchema)
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
requireTeamMode();
|
||||||
|
requireOrgPermission(context, { member: ["delete"] });
|
||||||
|
|
||||||
|
if (data.userId === context.userId) {
|
||||||
|
throw new AppError("FORBIDDEN", "You can't remove your own account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await AuthRepository.getMembership(
|
||||||
|
data.userId,
|
||||||
|
context.organizationId,
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppError("NOT_FOUND", "That user is not in this workspace.");
|
||||||
|
}
|
||||||
|
if (roleList(membership.role).includes("owner")) {
|
||||||
|
throw new AppError("FORBIDDEN", "The workspace owner can't be removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft removal: drop the membership and kill live sessions. The user row is
|
||||||
|
// kept so historical references (activity, audits) still resolve; the owner
|
||||||
|
// can re-add them later with a fresh password.
|
||||||
|
await AuthRepository.removeMembership(data.userId, context.organizationId);
|
||||||
|
await revokeUserSessions(data.userId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user